Sfoglia il codice sorgente

feat(onboarding): BB welcome modal + 25-step guided tour

  Closes the recurring "I added the printer but it isn't connecting" +
  "where is X feature" cluster that drove ~1/3 of invalid-tagged issues.
  New users now see a friendly welcome modal on first load and can take
  a guided walkthrough that highlights the load-bearing UI surface
  step-by-step.

  Backend: two new nullable columns on users (onboarding_status
  VARCHAR(64), onboarding_snoozed_until TIMESTAMP) added via inline
  migration with a one-shot backfill that marks every existing user as
  dismissed_at_migration -- pre-existing installs never see the welcome
  modal. GET + PATCH /api/v1/users/me/onboarding round-trip the state;
  OnboardingUpdate schema rejects dismissed_at_migration from the API
  (migration-internal only), validates the tour_in_progress:<step_id>
  form, and enforces snooze coherence (snoozed_until required only when
  status is snoozed). _users_column_exists helper mirrors
  _api_keys_column_exists and gates the backfill so restarts after
  new-user signups do not clobber the welcome-eligible NULL state.
  SQLite and Postgres both verified end-to-end.

  Frontend architecture: OnboardingProvider wraps the app inside
  AuthProvider; reads from the backend when auth is on, falls back to
  localStorage (bambuddy.onboarding_status + onboarding_snoozed_until)
  when auth is off so no-auth installs still get the welcome
  experience. A loadFailed gate prevents the welcome modal from popping
  over a backend outage -- we cannot distinguish "new user" from "GET
  errored" so we stay silent. OnboardingFlow driver picks between the
  Phase 0 welcome modal, the Phase 0.2 about modal, and the
  step-by-step TourEngine based on persisted status. Mounted inside
  BrowserRouter so the route guard's useLocation has its context (a
  sibling-of-Router placement crashed at runtime).

  Route guard: the overlay never renders on /setup, /login,
  /spoolbuddy/*, /camera/*, /overlay/*, or while requiresSetup is
  true -- fresh installs walk through the existing /setup flow
  uninterrupted, and the SpoolBuddy kiosk / OBS overlay / camera-popout
  windows never get a modal slapped over them.

  Tour engine: 25 steps targeting existing data-tour anchors --
  add-printer -> verify-connection -> printer-card sub-tour x5 (status
  row, AMS row, camera, controls, customize menu) -> add-spool ->
  bambu-cloud-sync -> sidebar overview x6 -> vp -> slicer-api ->
  makerworld -> obico -> integrations -> notifications -> users ->
  groups -> sso -> outro. Per-step route navigation via useNavigate,
  anchor polling at 100ms intervals with a 3-second cap (pages need a
  beat after navigation; never spin forever), box-shadow dimmed-
  spotlight cutout that pointer-events-through so the user can still
  interact, smart modal positioning (sidebar anchors to the right;
  page anchors below or flipped above based on viewport room),
  Back/Next/Skip with Escape as Skip, persistence on every Back/Next
  so mid-tour reloads resume at the same step.

  Conditional skip: each step exposes a skipIf(ctx) evaluated against
  at least one printer exists; verify-connection + the entire card
  sub-tour skip when no printer exists; makerworld skips when the user
  lacks makerworld:view (the sidebar entry is permission-gated and the
  anchor would not resolve); users / groups / sso skip when auth is
  off. Pre-render gate means the user never sees a flash of a step
  that is about to skip.

  Phase 1.1 "Lock the front door" auth step explicitly removed from
  the live tour -- /setup already prompts for the auth choice on fresh
  installs, and users who deliberately chose no-auth must not be
  nudged to enable it. The auth-card anchor stays for any future
  privacy-checkup surface.

  BB mascot: hero pose + 5 named poses (started / walk / almost /
  allset / help) sliced from the character sheet via Pillow into
  public/img/bb_*.webp. MascotIcon component takes a pose prop;
  per-step pose mapping in tourSteps.ts. TourLauncher sits in the
  sidebar footer as a BB icon that relaunches the tour from step 0
  and consumes the [data-tour="help-icon"] selector.

  WikiHelpIcon component lands the per-page question-mark icon on
  Queue / Archives / Stats / Maintenance / Files / Projects /
  Inventory -- opens the matching wiki.bambuddy.cool/{path}/ page in
  a new tab (chose new-tab over iframe because the wiki sets
  X-Frame-Options: DENY).

  27 new data-tour anchors across PrintersPage / SettingsPage /
  ProfilesPage / InventoryPage / Layout. i18n: 138 new keys under a
  new onboarding.* namespace, real translations in every locale
  (de/en/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW) -- no
  IDENTICAL_TO_EN_ALLOWED entries added.

  Backend tests: 12 in TestOnboardingAPI cover the round-trips and the
  validator branches. Frontend tests: 61 across 7 files -- anchor
  presence backstop, Phase 0 modal interaction, tour engine + step
  helpers, provider state machine (auth-on/off split, PATCH error
  fall-through, localStorage round-trip), route guard for every
  suppress path, WikiHelpIcon href/target/aria. Backend pytest
  5723/5723; frontend vitest 2154/2154; ESLint clean; frontend build
  clean; i18n parity clean at 5233 leaves x 11 locales.

  Companion docs: docs/onboarding-tour-plan.md carries the design
  (Phase 0-5 step inventory, anchor selector list, state model, asset
  inventory, resolved decisions, Implementation Status section that
  is authoritative for current shipped state).
maziggy 2 mesi fa
parent
commit
df66924066
62 ha cambiato i file con 4505 aggiunte e 88 eliminazioni
  1. 0 3
      CHANGELOG.md
  2. 53 0
      backend/app/api/routes/users.py
  3. 47 0
      backend/app/core/database.py
  4. 5 0
      backend/app/models/user.py
  5. 57 0
      backend/app/schemas/onboarding.py
  6. 179 0
      backend/tests/integration/test_auth_api.py
  7. 65 52
      docs/onboarding-tour-plan.md
  8. BIN
      frontend/public/img/bb_allset.webp
  9. BIN
      frontend/public/img/bb_almost.webp
  10. BIN
      frontend/public/img/bb_help.webp
  11. BIN
      frontend/public/img/bb_hero.webp
  12. BIN
      frontend/public/img/bb_started.webp
  13. BIN
      frontend/public/img/bb_walk.webp
  14. 5 0
      frontend/src/App.tsx
  15. 233 0
      frontend/src/__tests__/OnboardingContext.test.tsx
  16. 30 0
      frontend/src/__tests__/WikiHelpIcon.test.tsx
  17. 83 0
      frontend/src/__tests__/onboarding-anchors.test.ts
  18. 167 0
      frontend/src/__tests__/onboarding.test.tsx
  19. 124 0
      frontend/src/__tests__/onboardingRouteGuard.test.tsx
  20. 192 0
      frontend/src/__tests__/tourEngine.test.tsx
  21. 8 1
      frontend/src/__tests__/utils.tsx
  22. 24 0
      frontend/src/api/client.ts
  23. 3 0
      frontend/src/components/Layout.tsx
  24. 33 0
      frontend/src/components/WikiHelpIcon.tsx
  25. 83 0
      frontend/src/components/onboarding/AboutModal.tsx
  26. 41 0
      frontend/src/components/onboarding/MascotIcon.tsx
  27. 75 0
      frontend/src/components/onboarding/OnboardingFlow.tsx
  28. 271 0
      frontend/src/components/onboarding/TourEngine.tsx
  29. 28 0
      frontend/src/components/onboarding/TourLauncher.tsx
  30. 65 0
      frontend/src/components/onboarding/TourSpotlight.tsx
  31. 73 0
      frontend/src/components/onboarding/WelcomeModal.tsx
  32. 276 0
      frontend/src/components/onboarding/tourSteps.ts
  33. 120 0
      frontend/src/contexts/OnboardingContext.tsx
  34. 192 0
      frontend/src/i18n/locales/de.ts
  35. 194 0
      frontend/src/i18n/locales/en.ts
  36. 192 0
      frontend/src/i18n/locales/es.ts
  37. 192 0
      frontend/src/i18n/locales/fr.ts
  38. 192 0
      frontend/src/i18n/locales/it.ts
  39. 192 0
      frontend/src/i18n/locales/ja.ts
  40. 191 1
      frontend/src/i18n/locales/ko.ts
  41. 192 0
      frontend/src/i18n/locales/pt-BR.ts
  42. 192 0
      frontend/src/i18n/locales/tr.ts
  43. 192 0
      frontend/src/i18n/locales/zh-CN.ts
  44. 192 0
      frontend/src/i18n/locales/zh-TW.ts
  45. 2 0
      frontend/src/pages/ArchivesPage.tsx
  46. 2 0
      frontend/src/pages/FileManagerPage.tsx
  47. 3 1
      frontend/src/pages/InventoryPage.tsx
  48. 21 17
      frontend/src/pages/MaintenancePage.tsx
  49. 7 3
      frontend/src/pages/PrintersPage.tsx
  50. 1 1
      frontend/src/pages/ProfilesPage.tsx
  51. 3 1
      frontend/src/pages/ProjectsPage.tsx
  52. 2 0
      frontend/src/pages/QueuePage.tsx
  53. 8 7
      frontend/src/pages/SettingsPage.tsx
  54. 2 0
      frontend/src/pages/StatsPage.tsx
  55. 0 0
      static/assets/index-DdAEkh5e.js
  56. BIN
      static/img/bb_allset.webp
  57. BIN
      static/img/bb_almost.webp
  58. BIN
      static/img/bb_help.webp
  59. BIN
      static/img/bb_hero.webp
  60. BIN
      static/img/bb_started.webp
  61. BIN
      static/img/bb_walk.webp
  62. 1 1
      static/index.html

File diff suppressed because it is too large
+ 0 - 3
CHANGELOG.md


+ 53 - 0
backend/app/api/routes/users.py

@@ -34,6 +34,7 @@ from backend.app.models.user import User
 from backend.app.models.user_otp_code import UserOTPCode
 from backend.app.models.user_totp import UserTOTP
 from backend.app.schemas.auth import ChangePasswordRequest, GroupBrief, UserCreate, UserResponse, UserUpdate
+from backend.app.schemas.onboarding import OnboardingResponse, OnboardingUpdate
 from backend.app.services.email_service import (
     create_welcome_email_from_template,
     generate_secure_password,
@@ -526,3 +527,55 @@ async def change_own_password(
             pass  # Decode failure is harmless — token is already invalidated by password_changed_at
 
     return {"message": "Password changed successfully"}
+
+
+@router.get("/me/onboarding", response_model=OnboardingResponse)
+async def get_own_onboarding(
+    current_user: User | None = Depends(get_current_user_optional),
+):
+    """Return the current user's onboarding tour state.
+
+    Frontend polls this on app boot to decide whether to show the welcome
+    modal, snooze it, or skip straight to the dashboard. See
+    docs/onboarding-tour-plan.md Appendix B for the state model.
+    """
+    if not current_user:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Authentication required",
+        )
+    return OnboardingResponse(
+        status=current_user.onboarding_status,
+        snoozed_until=current_user.onboarding_snoozed_until,
+    )
+
+
+@router.patch("/me/onboarding", response_model=OnboardingResponse)
+async def update_own_onboarding(
+    update: OnboardingUpdate,
+    current_user: User | None = Depends(get_current_user_optional),
+    db: AsyncSession = Depends(get_db),
+):
+    """Update the current user's onboarding tour state."""
+    if not current_user:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Authentication required",
+        )
+
+    result = await db.execute(select(User).where(User.id == current_user.id))
+    user = result.scalar_one_or_none()
+    if not user:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="User not found",
+        )
+
+    user.onboarding_status = update.status
+    user.onboarding_snoozed_until = update.snoozed_until
+    await db.commit()
+
+    return OnboardingResponse(
+        status=user.onboarding_status,
+        snoozed_until=user.onboarding_snoozed_until,
+    )

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

@@ -425,6 +425,26 @@ async def _api_keys_column_exists(conn, column_name: str) -> bool:
     return result.scalar_one_or_none() is not None
 
 
+async def _users_column_exists(conn, column_name: str) -> bool:
+    """Return True if the named column exists on ``users``.
+
+    Mirrors ``_api_keys_column_exists`` — gates one-shot backfills against
+    the column-add migration so the UPDATE doesn't replay on every startup
+    and clobber post-migration NULLs (which would re-mark new users as
+    ``dismissed_at_migration`` and stop the welcome tour from ever showing).
+    """
+    from sqlalchemy import text
+
+    if is_sqlite():
+        result = await conn.execute(text("PRAGMA table_info(users)"))
+        return any(row[1] == column_name for row in result)
+    result = await conn.execute(
+        text("SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = :col"),
+        {"col": column_name},
+    )
+    return result.scalar_one_or_none() is not None
+
+
 async def _migrate_normalize_printer_ids(conn) -> None:
     from sqlalchemy import text
 
@@ -2910,6 +2930,33 @@ async def run_migrations(conn):
     else:
         await _safe_execute(conn, "ALTER TABLE users ADD COLUMN IF NOT EXISTS orca_cloud_pending_at TIMESTAMP")
 
+    # Migration: Add onboarding tour state columns (see
+    # docs/onboarding-tour-plan.md Appendices B + D). VARCHAR(64) is wide
+    # enough for the longest legal value (`tour_in_progress:<step_id>` with
+    # step_id capped at 40 chars by the OnboardingUpdate schema). DATETIME
+    # is SQLite-only — Postgres rejects it, same constraint as the
+    # password_changed_at + orca_cloud migrations above.
+    onboarding_existed = await _users_column_exists(conn, "onboarding_status")
+    await _safe_execute(conn, "ALTER TABLE users ADD COLUMN onboarding_status VARCHAR(64)")
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE users ADD COLUMN onboarding_snoozed_until DATETIME")
+    else:
+        await _safe_execute(
+            conn,
+            "ALTER TABLE users ADD COLUMN IF NOT EXISTS onboarding_snoozed_until TIMESTAMP",
+        )
+
+    # One-shot backfill: existing users predate the tour, so mark them
+    # 'dismissed_at_migration' to prevent the welcome modal from popping for
+    # already-onboarded users. Gated so re-runs don't clobber post-migration
+    # NULLs — the frontend uses NULL as the signal to show the welcome modal
+    # to new users.
+    if not onboarding_existed:
+        async with conn.begin_nested():
+            await conn.execute(
+                text("UPDATE users SET onboarding_status = 'dismissed_at_migration' WHERE onboarding_status IS NULL")
+            )
+
     # Data migration: drop the embedded 3MF Title (`print_name`) from library
     # file metadata so the FileManager displays the filename, not the title (#1489).
     await _migrate_drop_library_print_name(conn)

+ 5 - 0
backend/app/models/user.py

@@ -59,6 +59,11 @@ class User(Base):
     orca_cloud_pending_state: Mapped[str | None] = mapped_column(String(32), nullable=True, default=None)
     orca_cloud_pending_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
 
+    # Onboarding tour state. See docs/onboarding-tour-plan.md Appendix B for the
+    # state model — null means the welcome modal has not been shown yet.
+    onboarding_status: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None)
+    onboarding_snoozed_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
+
     # Relationship to groups through association table
     groups: Mapped[list[Group]] = relationship(
         "Group",

+ 57 - 0
backend/app/schemas/onboarding.py

@@ -0,0 +1,57 @@
+"""Pydantic schemas for the onboarding tour API.
+
+See docs/onboarding-tour-plan.md (Appendix B) for the state model.
+"""
+
+import re
+from datetime import datetime
+
+from pydantic import BaseModel, Field, field_validator, model_validator
+
+# Step IDs use dotted-numeric form ("1.2", "2.2b", "3.7"); bound to 40 chars
+# so the full "tour_in_progress:<step>" string fits inside VARCHAR(64).
+_STEP_ID_RE = re.compile(r"^[a-zA-Z0-9._-]{1,40}$")
+
+_TERMINAL_STATUSES = frozenset({"dismissed", "snoozed", "completed_tour"})
+
+
+class OnboardingResponse(BaseModel):
+    """Current onboarding state for the authenticated user.
+
+    `status` is null for users who have not yet seen the welcome modal.
+    """
+
+    status: str | None = None
+    snoozed_until: datetime | None = None
+
+
+class OnboardingUpdate(BaseModel):
+    """PATCH body for /api/v1/users/me/onboarding.
+
+    The `dismissed_at_migration` value is intentionally NOT acceptable here —
+    it is set once by the column-add migration to mark pre-existing users as
+    not-eligible and must not be replayable from the API.
+    """
+
+    status: str = Field(..., max_length=64)
+    snoozed_until: datetime | None = None
+
+    @field_validator("status")
+    @classmethod
+    def validate_status(cls, v: str) -> str:
+        if v in _TERMINAL_STATUSES:
+            return v
+        if v.startswith("tour_in_progress:"):
+            step_id = v[len("tour_in_progress:") :]
+            if _STEP_ID_RE.match(step_id):
+                return v
+        raise ValueError("status must be one of: dismissed, snoozed, completed_tour, or tour_in_progress:<step_id>")
+
+    @model_validator(mode="after")
+    def validate_snooze_coherence(self) -> "OnboardingUpdate":
+        if self.status == "snoozed":
+            if self.snoozed_until is None:
+                raise ValueError("snoozed_until is required when status='snoozed'")
+        elif self.snoozed_until is not None:
+            raise ValueError("snoozed_until is only valid when status='snoozed'")
+        return self

+ 179 - 0
backend/tests/integration/test_auth_api.py

@@ -991,3 +991,182 @@ class TestInputLengthValidation:
         )
         # Schema accepts it; auth may reject with 401 (auth disabled) or 400
         assert response.status_code != 422
+
+
+class TestOnboardingAPI:
+    """Integration tests for /api/v1/users/me/onboarding endpoints.
+
+    See docs/onboarding-tour-plan.md Appendix B for the state model.
+    """
+
+    @pytest.fixture
+    async def user_token(self, async_client: AsyncClient):
+        """Enable auth, create a regular user, return their bearer token."""
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "onboardingadmin",
+                "admin_password": "AdminPass1!",
+            },
+        )
+
+        admin_login = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "onboardingadmin", "password": "AdminPass1!"},
+        )
+        admin_token = admin_login.json()["access_token"]
+
+        await async_client.post(
+            "/api/v1/users/",
+            headers={"Authorization": f"Bearer {admin_token}"},
+            json={"username": "onboardinguser", "password": "Userpass123!"},
+        )
+
+        user_login = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "onboardinguser", "password": "Userpass123!"},
+        )
+        return user_login.json()["access_token"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_returns_null_for_new_user(self, async_client: AsyncClient, user_token: str):
+        """A newly-created user has no onboarding status set yet (welcome modal eligible)."""
+        response = await async_client.get(
+            "/api/v1/users/me/onboarding",
+            headers={"Authorization": f"Bearer {user_token}"},
+        )
+        assert response.status_code == 200
+        body = response.json()
+        assert body["status"] is None
+        assert body["snoozed_until"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_sets_dismissed(self, async_client: AsyncClient, user_token: str):
+        """PATCH with status=dismissed persists and is returned by subsequent GET."""
+        response = await async_client.patch(
+            "/api/v1/users/me/onboarding",
+            headers={"Authorization": f"Bearer {user_token}"},
+            json={"status": "dismissed"},
+        )
+        assert response.status_code == 200
+        assert response.json()["status"] == "dismissed"
+
+        followup = await async_client.get(
+            "/api/v1/users/me/onboarding",
+            headers={"Authorization": f"Bearer {user_token}"},
+        )
+        assert followup.json()["status"] == "dismissed"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_sets_snoozed_with_timestamp(self, async_client: AsyncClient, user_token: str):
+        """PATCH with status=snoozed + snoozed_until persists both fields."""
+        response = await async_client.patch(
+            "/api/v1/users/me/onboarding",
+            headers={"Authorization": f"Bearer {user_token}"},
+            json={"status": "snoozed", "snoozed_until": "2026-06-15T12:00:00+00:00"},
+        )
+        assert response.status_code == 200
+        body = response.json()
+        assert body["status"] == "snoozed"
+        assert body["snoozed_until"] is not None
+        assert body["snoozed_until"].startswith("2026-06-15T12:00:00")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_snoozed_without_timestamp_rejected(self, async_client: AsyncClient, user_token: str):
+        """status=snoozed without snoozed_until is a 422 — UI must supply both."""
+        response = await async_client.patch(
+            "/api/v1/users/me/onboarding",
+            headers={"Authorization": f"Bearer {user_token}"},
+            json={"status": "snoozed"},
+        )
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_non_snoozed_with_timestamp_rejected(self, async_client: AsyncClient, user_token: str):
+        """snoozed_until is meaningful only for snoozed status — reject otherwise."""
+        response = await async_client.patch(
+            "/api/v1/users/me/onboarding",
+            headers={"Authorization": f"Bearer {user_token}"},
+            json={"status": "dismissed", "snoozed_until": "2026-06-15T12:00:00+00:00"},
+        )
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_tour_in_progress_with_step_id(self, async_client: AsyncClient, user_token: str):
+        """tour_in_progress:<step_id> is accepted so the tour can resume on next session."""
+        response = await async_client.patch(
+            "/api/v1/users/me/onboarding",
+            headers={"Authorization": f"Bearer {user_token}"},
+            json={"status": "tour_in_progress:1.2"},
+        )
+        assert response.status_code == 200
+        assert response.json()["status"] == "tour_in_progress:1.2"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_completed_tour(self, async_client: AsyncClient, user_token: str):
+        """status=completed_tour is the happy-path terminal state."""
+        response = await async_client.patch(
+            "/api/v1/users/me/onboarding",
+            headers={"Authorization": f"Bearer {user_token}"},
+            json={"status": "completed_tour"},
+        )
+        assert response.status_code == 200
+        assert response.json()["status"] == "completed_tour"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_invalid_status_rejected(self, async_client: AsyncClient, user_token: str):
+        """Arbitrary status strings outside the allowed set are 422."""
+        response = await async_client.patch(
+            "/api/v1/users/me/onboarding",
+            headers={"Authorization": f"Bearer {user_token}"},
+            json={"status": "nonsense"},
+        )
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_dismissed_at_migration_rejected(self, async_client: AsyncClient, user_token: str):
+        """dismissed_at_migration is set only by the column-add migration, never by clients."""
+        response = await async_client.patch(
+            "/api/v1/users/me/onboarding",
+            headers={"Authorization": f"Bearer {user_token}"},
+            json={"status": "dismissed_at_migration"},
+        )
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_tour_in_progress_malformed_step_id_rejected(self, async_client: AsyncClient, user_token: str):
+        """Step IDs with characters outside the allowlist are 422."""
+        response = await async_client.patch(
+            "/api/v1/users/me/onboarding",
+            headers={"Authorization": f"Bearer {user_token}"},
+            json={"status": "tour_in_progress:step with spaces"},
+        )
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_requires_auth(self, async_client: AsyncClient):
+        """No bearer token → 401."""
+        response = await async_client.get("/api/v1/users/me/onboarding")
+        assert response.status_code == 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_requires_auth(self, async_client: AsyncClient):
+        """No bearer token → 401 (route is authenticated even though it has no permission gate)."""
+        response = await async_client.patch(
+            "/api/v1/users/me/onboarding",
+            json={"status": "dismissed"},
+        )
+        assert response.status_code == 401

+ 65 - 52
docs/onboarding-tour-plan.md

@@ -55,8 +55,10 @@
 
 ## Phase 1 - Critical setup (everyone needs these)
 
-### Step 1.1 - Authentication setup
-**Anchor:** Settings → Auth tab (`/settings?tab=auth`, `[data-tour="auth-card"]`)
+### Step 1.1 - Authentication setup ~~(implementation)~~
+**REMOVED from the live tour 2026-06-08.** The /setup page already prompts every fresh install for the auth choice, and users who deliberately ran with auth off should not be nudged to enable it. Step content kept here for design reference only; the auth-card anchor stays in the codebase in case a future surface (e.g. a privacy-and-security checkup) wants to reuse it.
+
+**Original anchor:** Settings → Users tab → Auth toggle card (`/settings?tab=users`, `[data-tour="auth-card"]`).
 **Conditions to show:** `auth_enabled === false` AND user is first admin
 **Content:**
 - BB "Thinking" expression
@@ -175,50 +177,24 @@
 **Buttons:** `Sign in to Bambu` / `Skip (use built-in defaults)`
 **Links:** wiki/features/profiles, wiki/security/credential-storage
 
-### Step 2.4 - The print queue
-**Anchor:** Queue page (`/queue`), `[data-tour="add-to-queue-button"]`
-**Content:**
-- "Queue prints across all your printers."
-- Three things the queue can do, with a one-line example each:
-  1. **Manual queue** — drag-and-drop files, pick which printer runs them.
-  2. **Auto-dispatch** — Bambuddy assigns queued jobs to idle printers automatically based on AMS / build-plate / capacity.
-  3. **Auto-drying** — queued PETG / PA jobs trigger AMS pre-drying so the spool is ready when dispatch fires (Queue Auto-Drying, see wiki/features/queue-drying).
-- "Power features for later: dependencies (`require_previous_success`), scheduled prints, batch jobs."
-**Buttons:** `Weiter` / `Show me how to add my first job` → opens Add to Queue modal
-**Links:** wiki/features/queue, wiki/features/queue-drying
-
-### Step 2.5 - Archives + statistics
-**Anchor:** Archives page (`/archives`), then Stats (`/stats`)
-**Content:**
-- "Every finished print is archived automatically."
-- Two-screen mini-tour:
-  - **Archives** — thumbnail, timelapse video, finish photo, gcode, sliced 3MF, runtime, weight, filaments used per slot. "Re-print directly from any archive."
-  - **Statistics** — print hours, filament used (by brand / material / color), energy cost, time-saved, success rate.
-- "Cost tracking pulls electricity price from settings — set it once and stats compute energy spend per print."
-**Buttons:** `Weiter`
-**Links:** wiki/features/archives, wiki/features/statistics
+### Step 2.4 - Sidebar overview (collapsed from former 2.4-2.7)
 
-### Step 2.6 - Maintenance tracking
-**Anchor:** Maintenance page (`/maintenance`), `[data-tour="add-maintenance-task"]`
+**Anchor:** sidebar, sequential 2-second focus highlights on each entry
 **Content:**
 - BB "Helpful" pose
-- "Bambuddy tracks consumables and maintenance per printer."
-- Examples: nozzle wear (by print hours), belt tension (by month), hotend swap (by filament weight), grease (by print count).
-- "Built-in tasks cover the standard intervals — add your own for custom maintenance."
-- Notifications fire via the same channel as print events (see Step 3.8).
-**Buttons:** `Weiter` / `Show me the defaults` → highlights default-task list
-**Links:** wiki/features/maintenance
-
-### Step 2.7 - File library + projects
-**Anchor:** File Manager (`/files`)
-**Content:**
-- "Your library lives here — upload 3MF, gcode, STL; group into projects; send to any printer."
-- Two sub-highlights:
-  - **Files page** — flat browser, upload, tag, search, send-to.
-  - **Projects page** — group files into a logical project (multi-plate models, multi-part assemblies). Track which plates are printed; mark project complete when done.
-- "External library folders (see Phase 3) let you mount a NAS share if your files don't live inside the container."
+- Headline: "Here's the rest of Bambuddy"
+- Six one-line callouts, no full per-page sub-tour:
+  1. **Print Queue** (`/queue`) — drag-and-drop jobs, auto-dispatch to idle printers, auto-drying for PETG/PA (Queue Auto-Drying, wiki/features/queue-drying).
+  2. **Archives** (`/archives`) — every finished print stored with timelapse, finish photo, gcode, 3MF; re-print from any row.
+  3. **Statistics** (`/stats`) — hours, filament by brand/material/color, energy cost (set electricity price once), success rate.
+  4. **Maintenance** (`/maintenance`) — nozzle wear, belt tension, hotend swap, grease intervals; built-in defaults + custom tasks. Notifications via the same channel as print events (see Step 3.8).
+  5. **Files** (`/files`) — 3MF/gcode/STL library, upload, tag, search, send-to-printer. External library folders for NAS mounts (see Phase 3.3).
+  6. **Projects** (`/projects`) — group files into logical projects, track which plates are printed.
+- Outro: "Every page has a `?` icon top-right that opens the matching wiki page in-context — full feature docs without leaving Bambuddy."
 **Buttons:** `Weiter`
-**Links:** wiki/features/library, wiki/features/projects
+**Links:** wiki/features/queue, wiki/features/archives, wiki/features/statistics, wiki/features/maintenance, wiki/features/library, wiki/features/projects
+
+**Rationale for the collapse (2026-06-08):** former steps 2.5/2.6/2.7 were "this page exists" content, not actionable setup. The inline `?` help icon (see Appendix G) carries that load without four extra mandatory modals.
 
 ---
 
@@ -374,9 +350,10 @@ Required selectors (full list — track in code review):
 - `[data-tour="auth-card"]` — SettingsPage auth tab
 - `[data-tour="add-spool-button"]` — InventoryPage
 - `[data-tour="bambu-cloud-sync"]` — ProfilesPage
-- `[data-tour="add-to-queue-button"]` — QueuePage
-- `[data-tour="add-maintenance-task"]` — MaintenancePage
-- `[data-tour="help-icon"]` — Sidebar bottom (NEW, to be added)
+- `[data-tour="sidebar-queue"]`, `[data-tour="sidebar-archives"]`, `[data-tour="sidebar-stats"]`, `[data-tour="sidebar-maintenance"]`, `[data-tour="sidebar-files"]`, `[data-tour="sidebar-projects"]` — Sidebar entries highlighted sequentially in Step 2.4. Wired via `data-tour={\`sidebar-${id}\`}` on the shared `NavLink`, so the attribute lands on every navItem; the tour script only targets these six.
+- `[data-tour="help-icon"]` — Sidebar bottom, on the `TourLauncher` BB button. Clicking it sets status to `tour_in_progress:<first step>` so the engine relaunches from step 0.
+
+**Anchor backstop test:** `frontend/src/__tests__/onboarding-anchors.test.ts` greps each anchor's source file and asserts presence. Source-level rather than DOM-render because most anchor hosts are gated by route + permission + sub-tab state that the engine's own tests will cover. Failures call out which anchor / which file so refactors can self-correct.
 
 A vitest test walks the tour against the rendered DOM and asserts every anchor resolves. PRs that change a component carrying a tour anchor have to either keep the anchor or update the tour script.
 
@@ -437,9 +414,11 @@ Expressions needed:
 - Thinking — Step 1.1
 - Focused — Step 1.3
 - Excited — Step 2.2 ("first spool added!" celebration)
-- Helpful — Steps 2.3, 2.6, 3.5
+- Helpful — Steps 2.3, 2.4, 3.5
 - Curious — Phase 3 gates
 
+**No new warning expression (decided 2026-06-08).** Red callouts in Step 1.2 (LAN-only / Dev mode / Docker bridge) use the system warning triangle inside the callout chrome. BB stays in "Almost there" pose for the surrounding step — friendly mascot + warning glyph reads "important but not scary."
+
 Branding elements: BB logo, leaf, filament spool, guidance arrow, setup checklist, foundation block — all already on the sheet.
 
 ---
@@ -460,10 +439,44 @@ GitHub `invalid`-tagged issues this tour explicitly addresses:
 
 ---
 
-## Open questions
+## Appendix G - Frontend `<WikiHelpIcon>` component
+
+New shared component, rendered top-right of every sidebar page that previously had a dedicated tour step. Carries the discovery load that former Steps 2.5/2.6/2.7 used to carry inside the tour.
+
+- Props: `path` (wiki page slug, e.g. `features/queue`).
+- Glyph: small `?` icon, consistent across all pages.
+- Click behaviour: opens the wiki page in an in-app modal iframe against `https://wiki.bambuddy.cool`; falls back to `target="_blank"` if iframe is blocked.
+- Pages requiring it at ship: Queue, Archives, Statistics, Maintenance, Files, Projects, Inventory. Add to additional pages as they grow.
+
+Backed by zero new backend routes — the wiki is already publicly hosted.
+
+---
 
-1. Should Step 1.2 include an interactive "test the access code without saving" button (calls a one-shot MQTT connect with the entered creds), so users get instant feedback before committing the printer row?
-2. Should Phase 3 be entirely opt-in (the user clicks "Show me power features" from Phase 5) instead of inline at the end of Phase 2?
-3. Should the SpoolBuddy steps (kiosk-related) appear in the main tour, or only after SpoolBuddy hardware is detected on the network?
-4. What's the right balance between "tour the page" (Phase 2.4 - 2.7) and "tooltips on the page itself"? Some of these could be inline help instead of tour steps.
-5. Does the mascot character set need a "wrong" / "warning" expression for the inline red callouts in Step 1.2, or do plain icons work?
+## Implementation status (2026-06-08)
+
+**Shipped end-to-end:**
+- Backend: `users.onboarding_status` + `users.onboarding_snoozed_until` columns, inline migration with `dismissed_at_migration` backfill, `GET` + `PATCH /api/v1/users/me/onboarding`, OnboardingResponse/OnboardingUpdate schemas with snooze-coherence validation. SQLite + Postgres both verified.
+- Frontend anchors: 26 `data-tour="..."` attributes — every step in the engine resolves. Backed by `src/__tests__/onboarding-anchors.test.ts`.
+- i18n: full `onboarding.*` namespace shipped across all 11 locales (en + de/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW), 138 keys.
+- `<WikiHelpIcon>`: shipped (Appendix G), integrated on Queue / Archives / Stats / Maintenance / Files / Projects / Inventory.
+- `OnboardingProvider` context: GET on auth-settle, localStorage fallback when auth is off, `loadFailed` gate so backend outages do not pop the welcome modal.
+- Phase 0 welcome + about modals.
+- Tour engine: **25-step path** covering every plan phase that has a real UI to anchor. Per-step route navigation, anchor polling with 3s timeout, dimmed spotlight cutout, back / next / skip / Escape, modal positioning that flips around sidebar vs page anchors, pre-render skip gate so no flash of skipped content.
+- Step order: add-printer → verify-connection → (card sub-tour ×5) → add-spool → bambu-cloud → (sidebar overview ×6) → vp → slicer-api → makerworld → obico → integrations → notifications → users → groups → sso → outro.
+- Conditional skipping: `printerCount` (skips add-printer when one exists; skips verify-connection + card sub-tour when none); `hasPermission` (skips makerworld step when the user lacks `makerworld:view`); `authEnabled` (gates users / groups / sso). The onboarding overlay also suppresses itself on `/setup`, `/login`, `/spoolbuddy/*`, `/camera/*`, `/overlay/*` and while `requiresSetup === true`, so fresh installs walk through /setup uninterrupted.
+- BB mascot: 5 distinct poses sliced from `screenshots/bb_bambuddy.webp` (started / walk / almost / allset / help) + hero, plus `MascotIcon` component. Per-step pose mapping in `tourSteps.ts`.
+- `TourLauncher` BB icon in the sidebar footer that relaunches the tour from step 0.
+
+**Intentionally NOT shipped:**
+- Phase 3.3 external library roots — no UI card today, configuration is env-var only (`BAMBUDDY_EXTERNAL_ROOTS`). Add the step when the UI ships.
+- Phase 3.7 Tailscale — no dedicated Settings card today. Add the step when one exists.
+- Per-pose mascot expressions (Happy / Thinking / Focused / Excited / Helpful / Curious) — pose covers the major moments; the expressions row is overkill for this surface.
+- Runtime verification against an auth-off Bambuddy — localStorage path is wired and unit-tested but not yet driven through a live browser session.
+
+## Resolved design decisions (2026-06-08)
+
+1. **Access-code pre-save test (Q1):** Not added. Step 1.3 verifies MQTT/FTP/RTSP immediately after save and Connection Diagnostic surfaces wrong-code errors cleanly — duplicate dry-run code path not worth the marginal time saving.
+2. **Phase 3 placement (Q2):** Stays inline with per-step "Interested?" gates. Hiding power features behind a Phase 5 "show me more" link defeats Goal #3 (surface major features for discovery).
+3. **SpoolBuddy step (Q3):** No dedicated step, no hardware-detection gate. SpoolBuddy stays as one of three input methods in Step 2.2 (RFID / SpoolBuddy kiosk / manual). Aligns with the [[spoolbuddy-what-it-is]] positioning — filament management, not a kiosk feature.
+4. **Tour-vs-tooltip for former 2.4-2.7 (Q4):** Collapsed into the new single Step 2.4 "Sidebar overview." Each affected page gets a `<WikiHelpIcon>` (Appendix G). Cuts ~4 modals from the mandatory path.
+5. **Mascot warning expression (Q5):** Not added. Red callouts in Step 1.2 use the system warning triangle; BB stays in "Almost there" pose for the surrounding step.

BIN
frontend/public/img/bb_allset.webp


BIN
frontend/public/img/bb_almost.webp


BIN
frontend/public/img/bb_help.webp


BIN
frontend/public/img/bb_hero.webp


BIN
frontend/public/img/bb_started.webp


BIN
frontend/public/img/bb_walk.webp


+ 5 - 0
frontend/src/App.tsx

@@ -31,6 +31,8 @@ import { ToastProvider } from './contexts/ToastContext';
 import { SliceJobTrackerProvider } from './contexts/SliceJobTrackerContext';
 import { AuthProvider, useAuth } from './contexts/AuthContext';
 import { ColorCatalogProvider } from './contexts/ColorCatalogContext';
+import { OnboardingProvider } from './contexts/OnboardingContext';
+import { OnboardingFlow } from './components/onboarding/OnboardingFlow';
 import { SpoolBuddyLayout } from './components/spoolbuddy/SpoolBuddyLayout';
 import { SpoolBuddyDashboard } from './pages/spoolbuddy/SpoolBuddyDashboard';
 import { SpoolBuddyAmsPage } from './pages/spoolbuddy/SpoolBuddyAmsPage';
@@ -165,8 +167,10 @@ function App() {
             <ThemeProvider>
             <ColorCatalogProvider>
             <SliceJobTrackerProvider>
+            <OnboardingProvider>
             <StreamTokenSync />
             <BrowserRouter>
+              <OnboardingFlow />
               <Routes>
                 {/* Setup page - only accessible if auth not enabled */}
                 <Route path="/setup" element={<SetupRoute><SetupPage /></SetupRoute>} />
@@ -217,6 +221,7 @@ function App() {
                 </Route>
               </Routes>
             </BrowserRouter>
+            </OnboardingProvider>
             </SliceJobTrackerProvider>
             </ColorCatalogProvider>
             </ThemeProvider>

+ 233 - 0
frontend/src/__tests__/OnboardingContext.test.tsx

@@ -0,0 +1,233 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderHook, waitFor, act } from '@testing-library/react';
+import React from 'react';
+import { OnboardingProvider, useOnboarding } from '../contexts/OnboardingContext';
+import * as AuthContextModule from '../contexts/AuthContext';
+import * as ApiClient from '../api/client';
+import type { Permission, UserResponse } from '../api/client';
+
+function wrap({ children }: { children: React.ReactNode }) {
+  return <OnboardingProvider>{children}</OnboardingProvider>;
+}
+
+function mockAuth(opts: { authEnabled: boolean; user?: UserResponse | null; loading?: boolean }) {
+  const user: UserResponse | null = opts.user ?? null;
+  vi.spyOn(AuthContextModule, 'useAuth').mockReturnValue({
+    user,
+    authEnabled: opts.authEnabled,
+    requiresSetup: false,
+    loading: opts.loading ?? false,
+    isAdmin: false,
+    login: vi.fn(),
+    loginWithToken: vi.fn(),
+    logout: vi.fn(),
+    refreshUser: vi.fn(),
+    refreshAuth: vi.fn(),
+    hasPermission: (_: Permission) => false,
+    hasAnyPermission: (..._: Permission[]) => false,
+    hasAllPermissions: (..._: Permission[]) => false,
+    canModify: () => false,
+  });
+}
+
+const fakeUser: UserResponse = {
+  id: 1,
+  username: 'tester',
+  email: null,
+  role: 'admin',
+  is_active: true,
+  is_admin: true,
+  auth_source: 'local',
+  groups: [],
+  permissions: [],
+  created_at: '2024-01-01T00:00:00Z',
+};
+
+beforeEach(() => {
+  vi.restoreAllMocks();
+  // localStorage is mocked in setup.ts — reset call counts per test.
+  vi.mocked(window.localStorage.getItem).mockReset();
+  vi.mocked(window.localStorage.setItem).mockReset();
+  vi.mocked(window.localStorage.removeItem).mockReset();
+});
+
+describe('OnboardingProvider — auth on, user logged in', () => {
+  it('GETs the user state and exposes status / snoozedUntil from the response', async () => {
+    mockAuth({ authEnabled: true, user: fakeUser });
+    vi.spyOn(ApiClient.api, 'getOnboarding').mockResolvedValue({
+      status: 'completed_tour',
+      snoozed_until: null,
+    });
+
+    const { result } = renderHook(() => useOnboarding(), { wrapper: wrap });
+
+    await waitFor(() => {
+      expect(result.current.isLoaded).toBe(true);
+    });
+    expect(result.current.status).toBe('completed_tour');
+    expect(result.current.snoozedUntil).toBeNull();
+    expect(result.current.loadFailed).toBe(false);
+  });
+
+  it('sets loadFailed when the GET errors so the welcome modal stays hidden', async () => {
+    mockAuth({ authEnabled: true, user: fakeUser });
+    vi.spyOn(ApiClient.api, 'getOnboarding').mockRejectedValue(new Error('5xx'));
+
+    const { result } = renderHook(() => useOnboarding(), { wrapper: wrap });
+
+    await waitFor(() => {
+      expect(result.current.isLoaded).toBe(true);
+    });
+    expect(result.current.loadFailed).toBe(true);
+    expect(result.current.status).toBeNull();
+  });
+
+  it('marks loadFailed=true when auth is on but there is no active user — "me" cannot be queried', async () => {
+    mockAuth({ authEnabled: true, user: null });
+    const spy = vi.spyOn(ApiClient.api, 'getOnboarding');
+
+    const { result } = renderHook(() => useOnboarding(), { wrapper: wrap });
+
+    await waitFor(() => {
+      expect(result.current.isLoaded).toBe(true);
+    });
+    expect(result.current.loadFailed).toBe(true);
+    expect(spy).not.toHaveBeenCalled();
+  });
+
+  it('PATCHes the backend when setStatus is called and updates local state from the response', async () => {
+    mockAuth({ authEnabled: true, user: fakeUser });
+    vi.spyOn(ApiClient.api, 'getOnboarding').mockResolvedValue({
+      status: null,
+      snoozed_until: null,
+    });
+    const patchSpy = vi.spyOn(ApiClient.api, 'updateOnboarding').mockResolvedValue({
+      status: 'dismissed',
+      snoozed_until: null,
+    });
+
+    const { result } = renderHook(() => useOnboarding(), { wrapper: wrap });
+    await waitFor(() => expect(result.current.isLoaded).toBe(true));
+
+    await act(async () => {
+      await result.current.setStatus('dismissed');
+    });
+
+    expect(patchSpy).toHaveBeenCalledWith({ status: 'dismissed' });
+    expect(result.current.status).toBe('dismissed');
+  });
+
+  it('keeps the UI responsive when the PATCH errors — falls through to local state so the modal still closes', async () => {
+    mockAuth({ authEnabled: true, user: fakeUser });
+    vi.spyOn(ApiClient.api, 'getOnboarding').mockResolvedValue({
+      status: null,
+      snoozed_until: null,
+    });
+    vi.spyOn(ApiClient.api, 'updateOnboarding').mockRejectedValue(new Error('5xx'));
+
+    const { result } = renderHook(() => useOnboarding(), { wrapper: wrap });
+    await waitFor(() => expect(result.current.isLoaded).toBe(true));
+
+    await act(async () => {
+      await result.current.setStatus('dismissed');
+    });
+    expect(result.current.status).toBe('dismissed');
+  });
+
+  it('includes snoozed_until in the PATCH body when status is snoozed', async () => {
+    mockAuth({ authEnabled: true, user: fakeUser });
+    vi.spyOn(ApiClient.api, 'getOnboarding').mockResolvedValue({
+      status: null,
+      snoozed_until: null,
+    });
+    const patchSpy = vi.spyOn(ApiClient.api, 'updateOnboarding').mockResolvedValue({
+      status: 'snoozed',
+      snoozed_until: '2026-06-15T00:00:00Z',
+    });
+
+    const { result } = renderHook(() => useOnboarding(), { wrapper: wrap });
+    await waitFor(() => expect(result.current.isLoaded).toBe(true));
+
+    await act(async () => {
+      await result.current.setStatus('snoozed', '2026-06-15T00:00:00Z');
+    });
+    expect(patchSpy).toHaveBeenCalledWith({
+      status: 'snoozed',
+      snoozed_until: '2026-06-15T00:00:00Z',
+    });
+    expect(result.current.snoozedUntil).toBe('2026-06-15T00:00:00Z');
+  });
+});
+
+describe('OnboardingProvider — auth off (localStorage path)', () => {
+  it('reads status from localStorage on mount', async () => {
+    mockAuth({ authEnabled: false });
+    vi.mocked(window.localStorage.getItem).mockImplementation((key: string) => {
+      if (key === 'bambuddy.onboarding_status') return 'dismissed';
+      return null;
+    });
+
+    const { result } = renderHook(() => useOnboarding(), { wrapper: wrap });
+
+    await waitFor(() => expect(result.current.isLoaded).toBe(true));
+    expect(result.current.status).toBe('dismissed');
+    expect(result.current.loadFailed).toBe(false);
+  });
+
+  it('writes status to localStorage when setStatus is called', async () => {
+    mockAuth({ authEnabled: false });
+    vi.mocked(window.localStorage.getItem).mockReturnValue(null);
+    const patchSpy = vi.spyOn(ApiClient.api, 'updateOnboarding');
+
+    const { result } = renderHook(() => useOnboarding(), { wrapper: wrap });
+    await waitFor(() => expect(result.current.isLoaded).toBe(true));
+
+    await act(async () => {
+      await result.current.setStatus('dismissed');
+    });
+
+    expect(window.localStorage.setItem).toHaveBeenCalledWith(
+      'bambuddy.onboarding_status',
+      'dismissed',
+    );
+    expect(patchSpy).not.toHaveBeenCalled();
+    expect(result.current.status).toBe('dismissed');
+  });
+
+  it('writes snoozed_until to localStorage when status is snoozed, removes it otherwise', async () => {
+    mockAuth({ authEnabled: false });
+    vi.mocked(window.localStorage.getItem).mockReturnValue(null);
+
+    const { result } = renderHook(() => useOnboarding(), { wrapper: wrap });
+    await waitFor(() => expect(result.current.isLoaded).toBe(true));
+
+    await act(async () => {
+      await result.current.setStatus('snoozed', '2026-06-15T00:00:00Z');
+    });
+    expect(window.localStorage.setItem).toHaveBeenCalledWith(
+      'bambuddy.onboarding_snoozed_until',
+      '2026-06-15T00:00:00Z',
+    );
+    expect(result.current.snoozedUntil).toBe('2026-06-15T00:00:00Z');
+
+    await act(async () => {
+      await result.current.setStatus('dismissed');
+    });
+    expect(window.localStorage.removeItem).toHaveBeenCalledWith(
+      'bambuddy.onboarding_snoozed_until',
+    );
+    expect(result.current.snoozedUntil).toBeNull();
+  });
+});
+
+describe('OnboardingProvider — waiting on auth', () => {
+  it('does not fire the GET while authLoading is true', () => {
+    mockAuth({ authEnabled: true, user: fakeUser, loading: true });
+    const spy = vi.spyOn(ApiClient.api, 'getOnboarding');
+
+    const { result } = renderHook(() => useOnboarding(), { wrapper: wrap });
+
+    expect(spy).not.toHaveBeenCalled();
+    expect(result.current.isLoaded).toBe(false);
+  });
+});

+ 30 - 0
frontend/src/__tests__/WikiHelpIcon.test.tsx

@@ -0,0 +1,30 @@
+import { describe, it, expect } from 'vitest';
+import { render } from './utils';
+import { screen } from '@testing-library/react';
+import { WikiHelpIcon } from '../components/WikiHelpIcon';
+
+describe('WikiHelpIcon', () => {
+  it('renders an external link to the wiki base + path with trailing slash', () => {
+    render(<WikiHelpIcon path="features/queue" />);
+    const link = screen.getByRole('link');
+    expect(link.getAttribute('href')).toBe('https://wiki.bambuddy.cool/features/queue/');
+  });
+
+  it('opens in a new tab with safe rel attribute', () => {
+    render(<WikiHelpIcon path="features/archives" />);
+    const link = screen.getByRole('link');
+    expect(link.getAttribute('target')).toBe('_blank');
+    expect(link.getAttribute('rel')).toBe('noopener noreferrer');
+  });
+
+  it('exposes a localized aria-label so screen readers announce the destination', () => {
+    render(<WikiHelpIcon path="features/inventory" />);
+    const link = screen.getByRole('link');
+    const label = link.getAttribute('aria-label');
+    expect(label).toBeTruthy();
+    expect(label).not.toBe('');
+    // The key should resolve — if i18n hasn't loaded, getByRole would still
+    // pass but the label would be the raw key string. Guard against that.
+    expect(label).not.toContain('onboarding.helpIcon');
+  });
+});

+ 83 - 0
frontend/src/__tests__/onboarding-anchors.test.ts

@@ -0,0 +1,83 @@
+/**
+ * Regression backstop for the onboarding tour anchors documented in
+ * docs/onboarding-tour-plan.md Appendix A. The future tour engine will
+ * target each `[data-tour="..."]` selector listed below — if a component
+ * gets refactored without preserving its anchor, the corresponding tour
+ * step will silently fail at runtime. This test fails the PR instead.
+ *
+ * Source-level grep rather than DOM render: most anchor hosts are inside
+ * pages gated by route + permission + sub-tab state that would require
+ * extensive mocking to render at all (e.g. the auth-card lives inside
+ * `usersSubTab === 'users'`). Source presence is the load-bearing
+ * invariant; the tour engine's own tests will exercise rendered behaviour.
+ */
+
+import { dirname, resolve } from 'node:path';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { describe, it, expect } from 'vitest';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const FRONTEND_ROOT = resolve(__dirname, '..', '..');
+
+interface AnchorSpec {
+  anchor: string;
+  file: string;
+}
+
+const LITERAL_ANCHORS: AnchorSpec[] = [
+  { anchor: 'add-printer-button', file: 'src/pages/PrintersPage.tsx' },
+  { anchor: 'printer-status-pill', file: 'src/pages/PrintersPage.tsx' },
+  { anchor: 'printer-status-row', file: 'src/pages/PrintersPage.tsx' },
+  { anchor: 'printer-ams-row', file: 'src/pages/PrintersPage.tsx' },
+  { anchor: 'printer-camera', file: 'src/pages/PrintersPage.tsx' },
+  { anchor: 'printer-controls', file: 'src/pages/PrintersPage.tsx' },
+  { anchor: 'printer-customize', file: 'src/pages/PrintersPage.tsx' },
+  { anchor: 'auth-card', file: 'src/pages/SettingsPage.tsx' },
+  { anchor: 'vp-card', file: 'src/pages/SettingsPage.tsx' },
+  { anchor: 'slicer-api-card', file: 'src/pages/SettingsPage.tsx' },
+  { anchor: 'integrations-card', file: 'src/pages/SettingsPage.tsx' },
+  { anchor: 'obico-card', file: 'src/pages/SettingsPage.tsx' },
+  { anchor: 'add-user-button', file: 'src/pages/SettingsPage.tsx' },
+  { anchor: 'groups-section', file: 'src/pages/SettingsPage.tsx' },
+  { anchor: 'sso-section', file: 'src/pages/SettingsPage.tsx' },
+  { anchor: 'add-spool-button', file: 'src/pages/InventoryPage.tsx' },
+  { anchor: 'bambu-cloud-sync', file: 'src/pages/ProfilesPage.tsx' },
+];
+
+// Sidebar entries share a single `data-tour={`sidebar-${id}`}` expression
+// on the NavLink — assert the template AND that each plan-listed id exists
+// in `defaultNavItems` so the runtime selector actually resolves.
+const SIDEBAR_IDS = [
+  'queue',
+  'archives',
+  'stats',
+  'maintenance',
+  'files',
+  'projects',
+];
+
+function readSource(relativePath: string): string {
+  return readFileSync(resolve(FRONTEND_ROOT, relativePath), 'utf8');
+}
+
+describe('Onboarding tour anchors', () => {
+  for (const { anchor, file } of LITERAL_ANCHORS) {
+    it(`[data-tour="${anchor}"] is present in ${file}`, () => {
+      const contents = readSource(file);
+      expect(contents).toMatch(new RegExp(`data-tour="${anchor}"`));
+    });
+  }
+
+  it('Sidebar NavLink applies the data-tour={`sidebar-${id}`} template', () => {
+    const layout = readSource('src/components/Layout.tsx');
+    expect(layout).toContain('data-tour={`sidebar-${id}`}');
+  });
+
+  for (const id of SIDEBAR_IDS) {
+    it(`defaultNavItems includes '${id}' so [data-tour="sidebar-${id}"] resolves at runtime`, () => {
+      const layout = readSource('src/components/Layout.tsx');
+      expect(layout).toMatch(new RegExp(`\\{\\s*id:\\s*'${id}'`));
+    });
+  }
+});

+ 167 - 0
frontend/src/__tests__/onboarding.test.tsx

@@ -0,0 +1,167 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor } from './utils';
+import userEvent from '@testing-library/user-event';
+import { OnboardingFlow } from '../components/onboarding/OnboardingFlow';
+import * as OnboardingContextModule from '../contexts/OnboardingContext';
+
+const setStatusMock = vi.fn().mockResolvedValue(undefined);
+
+function mockOnboarding(overrides: Partial<ReturnType<typeof OnboardingContextModule.useOnboarding>>) {
+  vi.spyOn(OnboardingContextModule, 'useOnboarding').mockReturnValue({
+    status: null,
+    snoozedUntil: null,
+    isLoaded: true,
+    loadFailed: false,
+    setStatus: setStatusMock,
+    ...overrides,
+  });
+}
+
+beforeEach(() => {
+  setStatusMock.mockClear();
+});
+
+describe('OnboardingFlow eligibility', () => {
+  it('does not render anything while the provider is still loading', () => {
+    mockOnboarding({ isLoaded: false });
+    render(<OnboardingFlow />);
+    expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+  });
+
+  it('does not render when the initial GET errored — distinguishing "new user" from "API down" is unsafe', () => {
+    mockOnboarding({ loadFailed: true });
+    render(<OnboardingFlow />);
+    expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+  });
+
+  it('renders the welcome modal when status is null (new user)', () => {
+    mockOnboarding({ status: null });
+    render(<OnboardingFlow />);
+    expect(screen.getByRole('dialog')).toBeInTheDocument();
+  });
+
+  it('stays hidden when status is dismissed', () => {
+    mockOnboarding({ status: 'dismissed' });
+    render(<OnboardingFlow />);
+    expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+  });
+
+  it('stays hidden when status is completed_tour', () => {
+    mockOnboarding({ status: 'completed_tour' });
+    render(<OnboardingFlow />);
+    expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+  });
+
+  it('stays hidden when status is dismissed_at_migration', () => {
+    mockOnboarding({ status: 'dismissed_at_migration' });
+    render(<OnboardingFlow />);
+    expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+  });
+
+  it('stays hidden when the snooze window has not yet elapsed', () => {
+    const future = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
+    mockOnboarding({ status: 'snoozed', snoozedUntil: future });
+    render(<OnboardingFlow />);
+    expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+  });
+
+  it('renders the welcome modal again once the snooze window has elapsed', () => {
+    const past = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
+    mockOnboarding({ status: 'snoozed', snoozedUntil: past });
+    render(<OnboardingFlow />);
+    expect(screen.getByRole('dialog')).toBeInTheDocument();
+  });
+
+  it('falls back to hidden when snoozedUntil is malformed', () => {
+    mockOnboarding({ status: 'snoozed', snoozedUntil: 'not-a-date' });
+    render(<OnboardingFlow />);
+    expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+  });
+});
+
+describe('Welcome modal interactions', () => {
+  it('persists "dismissed" when the user clicks "I\'m experienced"', async () => {
+    mockOnboarding({ status: null });
+    render(<OnboardingFlow />);
+    const user = userEvent.setup();
+    // The "experienced" button has the second-largest weight in the modal
+    // (first is "Start tour", which advances rather than persists).
+    const buttons = screen.getAllByRole('button');
+    const experiencedButton = buttons[1];
+    await user.click(experiencedButton);
+    expect(setStatusMock).toHaveBeenCalledWith('dismissed');
+  });
+
+  it('persists "snoozed" with a future ISO timestamp when the user clicks "Remind me later"', async () => {
+    mockOnboarding({ status: null });
+    render(<OnboardingFlow />);
+    const user = userEvent.setup();
+    const buttons = screen.getAllByRole('button');
+    const snoozeButton = buttons[2];
+    await user.click(snoozeButton);
+    expect(setStatusMock).toHaveBeenCalledTimes(1);
+    const [status, snoozedUntil] = setStatusMock.mock.calls[0];
+    expect(status).toBe('snoozed');
+    expect(typeof snoozedUntil).toBe('string');
+    const snoozeMs = new Date(snoozedUntil as string).getTime();
+    const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
+    // Allow a small skew window — the test runs concurrently with the click.
+    expect(snoozeMs).toBeGreaterThan(Date.now() + sevenDaysMs - 5000);
+    expect(snoozeMs).toBeLessThan(Date.now() + sevenDaysMs + 5000);
+  });
+
+  it('advances to the About modal when the user clicks "Start tour"', async () => {
+    mockOnboarding({ status: null });
+    render(<OnboardingFlow />);
+    const user = userEvent.setup();
+    const buttons = screen.getAllByRole('button');
+    // First button is "Start tour"
+    await user.click(buttons[0]);
+    // setStatus should NOT be called yet — advance happens via local phase state
+    expect(setStatusMock).not.toHaveBeenCalled();
+    // The dialog is still mounted (now the About modal); its labelled-by id changes.
+    await waitFor(() => {
+      const dialog = screen.getByRole('dialog');
+      expect(dialog.getAttribute('aria-labelledby')).toBe('onboarding-about-title');
+    });
+  });
+});
+
+describe('About modal interactions', () => {
+  it('launches the tour engine (tour_in_progress:<first-step>) when the user clicks Done from the About modal', async () => {
+    mockOnboarding({ status: null });
+    render(<OnboardingFlow />);
+    const user = userEvent.setup();
+    // Click Start tour to get to the About modal
+    const welcomeButtons = screen.getAllByRole('button');
+    await user.click(welcomeButtons[0]);
+    await waitFor(() => {
+      const dialog = screen.getByRole('dialog');
+      expect(dialog.getAttribute('aria-labelledby')).toBe('onboarding-about-title');
+    });
+    // About modal has two buttons: Skip (left), Done (right)
+    const aboutButtons = screen.getAllByRole('button');
+    await user.click(aboutButtons[aboutButtons.length - 1]);
+    // Done should set status to the first tour step, NOT completed_tour. The
+    // engine takes over once OnboardingFlow sees the tour_in_progress prefix.
+    expect(setStatusMock).toHaveBeenCalledTimes(1);
+    const [status] = setStatusMock.mock.calls[0];
+    expect(status).toMatch(/^tour_in_progress:/);
+  });
+
+  it('persists "dismissed" when the user clicks Skip from the About modal', async () => {
+    mockOnboarding({ status: null });
+    render(<OnboardingFlow />);
+    const user = userEvent.setup();
+    const welcomeButtons = screen.getAllByRole('button');
+    await user.click(welcomeButtons[0]);
+    await waitFor(() => {
+      const dialog = screen.getByRole('dialog');
+      expect(dialog.getAttribute('aria-labelledby')).toBe('onboarding-about-title');
+    });
+    const aboutButtons = screen.getAllByRole('button');
+    // Skip is the first of the two action buttons
+    await user.click(aboutButtons[0]);
+    expect(setStatusMock).toHaveBeenCalledWith('dismissed');
+  });
+});

+ 124 - 0
frontend/src/__tests__/onboardingRouteGuard.test.tsx

@@ -0,0 +1,124 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import React from 'react';
+import { OnboardingFlow } from '../components/onboarding/OnboardingFlow';
+import * as OnboardingContextModule from '../contexts/OnboardingContext';
+import * as AuthContextModule from '../contexts/AuthContext';
+import type { Permission, UserResponse } from '../api/client';
+
+// We bypass the shared test util here because it provides its own
+// BrowserRouter and react-router rejects nested routers. The OnboardingFlow
+// uses only useLocation + the mocked useAuth + useOnboarding, so MemoryRouter
+// alone is the minimal context it needs.
+function renderAt(path: string) {
+  return render(
+    <MemoryRouter initialEntries={[path]}>
+      <OnboardingFlow />
+    </MemoryRouter>,
+  );
+}
+
+const setStatusMock = vi.fn().mockResolvedValue(undefined);
+
+function mockOnboardingStatus(status: string | null) {
+  vi.spyOn(OnboardingContextModule, 'useOnboarding').mockReturnValue({
+    status,
+    snoozedUntil: null,
+    isLoaded: true,
+    loadFailed: false,
+    setStatus: setStatusMock,
+  });
+}
+
+function mockAuth(opts: { authEnabled: boolean; requiresSetup?: boolean; user?: UserResponse | null }) {
+  vi.spyOn(AuthContextModule, 'useAuth').mockReturnValue({
+    user: opts.user ?? null,
+    authEnabled: opts.authEnabled,
+    requiresSetup: opts.requiresSetup ?? false,
+    loading: false,
+    isAdmin: false,
+    login: vi.fn(),
+    loginWithToken: vi.fn(),
+    logout: vi.fn(),
+    refreshUser: vi.fn(),
+    refreshAuth: vi.fn(),
+    hasPermission: (_: Permission) => false,
+    hasAnyPermission: (..._: Permission[]) => false,
+    hasAllPermissions: (..._: Permission[]) => false,
+    canModify: () => false,
+  });
+}
+
+beforeEach(() => {
+  setStatusMock.mockClear();
+  vi.restoreAllMocks();
+});
+
+describe('OnboardingFlow route guard', () => {
+  it('renders the welcome modal on the main app route when the user has never seen the tour', () => {
+    mockOnboardingStatus(null);
+    mockAuth({ authEnabled: false });
+    renderAt('/');
+    expect(screen.getByRole('dialog')).toBeInTheDocument();
+  });
+
+  it('suppresses the welcome modal during the fresh-install /setup flow', () => {
+    mockOnboardingStatus(null);
+    mockAuth({ authEnabled: false, requiresSetup: true });
+    renderAt('/setup');
+    expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+  });
+
+  it('suppresses the welcome modal even when requiresSetup is false if the user is on /setup', () => {
+    mockOnboardingStatus(null);
+    mockAuth({ authEnabled: false, requiresSetup: false });
+    renderAt('/setup');
+    expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+  });
+
+  it('suppresses the welcome modal on /login', () => {
+    mockOnboardingStatus(null);
+    mockAuth({ authEnabled: true });
+    renderAt('/login');
+    expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+  });
+
+  it('suppresses the welcome modal on the SpoolBuddy kiosk surface', () => {
+    mockOnboardingStatus(null);
+    mockAuth({ authEnabled: false });
+    renderAt('/spoolbuddy');
+    expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+  });
+
+  it('suppresses the welcome modal on nested SpoolBuddy routes', () => {
+    mockOnboardingStatus(null);
+    mockAuth({ authEnabled: false });
+    renderAt('/spoolbuddy/write-tag');
+    expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+  });
+
+  it('suppresses the welcome modal on the standalone /camera/:id route', () => {
+    mockOnboardingStatus(null);
+    mockAuth({ authEnabled: false });
+    renderAt('/camera/3');
+    expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+  });
+
+  it('suppresses the welcome modal on the OBS overlay route', () => {
+    mockOnboardingStatus(null);
+    mockAuth({ authEnabled: false });
+    renderAt('/overlay/3');
+    expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+  });
+
+  it('suppresses the welcome modal while requiresSetup is true even on a main app route', () => {
+    // Edge case: requiresSetup somehow flips back to true while the user is
+    // on /. We should still not pop the modal — the only thing that matters
+    // is that setup is unfinished.
+    mockOnboardingStatus(null);
+    mockAuth({ authEnabled: false, requiresSetup: true });
+    renderAt('/');
+    expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+  });
+});

+ 192 - 0
frontend/src/__tests__/tourEngine.test.tsx

@@ -0,0 +1,192 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor } from './utils';
+import userEvent from '@testing-library/user-event';
+import { OnboardingFlow } from '../components/onboarding/OnboardingFlow';
+import * as OnboardingContextModule from '../contexts/OnboardingContext';
+import * as AuthContextModule from '../contexts/AuthContext';
+import * as ApiClient from '../api/client';
+import {
+  TOUR_STEPS,
+  stepIndexFromStatus,
+  statusForStep,
+} from '../components/onboarding/tourSteps';
+import type { Printer, UserResponse, Permission } from '../api/client';
+
+const setStatusMock = vi.fn().mockResolvedValue(undefined);
+
+function mockOnboardingStatus(status: string | null) {
+  vi.spyOn(OnboardingContextModule, 'useOnboarding').mockReturnValue({
+    status,
+    snoozedUntil: null,
+    isLoaded: true,
+    loadFailed: false,
+    setStatus: setStatusMock,
+  });
+}
+
+function mockAuth(authEnabled: boolean, user: UserResponse | null = null) {
+  vi.spyOn(AuthContextModule, 'useAuth').mockReturnValue({
+    user,
+    authEnabled,
+    requiresSetup: false,
+    loading: false,
+    isAdmin: false,
+    login: vi.fn(),
+    loginWithToken: vi.fn(),
+    logout: vi.fn(),
+    refreshUser: vi.fn(),
+    refreshAuth: vi.fn(),
+    hasPermission: (_: Permission) => false,
+    hasAnyPermission: (..._: Permission[]) => false,
+    hasAllPermissions: (..._: Permission[]) => false,
+    canModify: () => false,
+  });
+}
+
+beforeEach(() => {
+  setStatusMock.mockClear();
+  vi.restoreAllMocks();
+  // Default to zero printers so steps with `skipIf: printerCount > 0` do
+  // not auto-advance and tests that exercise the Back/Skip/Next buttons can
+  // actually find them. MSW's default `mockPrinters` returns one printer,
+  // which would otherwise skip the add-printer step before the click lands.
+  vi.spyOn(ApiClient.api, 'getPrinters').mockResolvedValue([] as Printer[]);
+});
+
+describe('tourSteps helpers', () => {
+  it('round-trips index → status → index for every step', () => {
+    for (let i = 0; i < TOUR_STEPS.length; i++) {
+      const status = statusForStep(i);
+      expect(status).toBe(`tour_in_progress:${TOUR_STEPS[i].id}`);
+      expect(stepIndexFromStatus(status)).toBe(i);
+    }
+  });
+
+  it('returns -1 for non-tour-progress statuses', () => {
+    expect(stepIndexFromStatus(null)).toBe(-1);
+    expect(stepIndexFromStatus('dismissed')).toBe(-1);
+    expect(stepIndexFromStatus('completed_tour')).toBe(-1);
+    expect(stepIndexFromStatus('snoozed')).toBe(-1);
+    expect(stepIndexFromStatus('tour_in_progress:unknown-step')).toBe(-1);
+  });
+
+  it('throws when building a status for an out-of-range index', () => {
+    expect(() => statusForStep(TOUR_STEPS.length)).toThrow();
+    expect(() => statusForStep(-1)).toThrow();
+  });
+});
+
+describe('TourEngine wiring', () => {
+  it('renders the engine modal when status is a valid tour step', () => {
+    // Pick the outro step — it has no anchor so we skip the 3s anchor-poll
+    // timeout and the engine centres the modal immediately.
+    mockOnboardingStatus(statusForStep(TOUR_STEPS.length - 1));
+    render(<OnboardingFlow />);
+    const dialog = screen.getByRole('dialog');
+    expect(dialog.getAttribute('aria-labelledby')).toBe('tour-step-title');
+  });
+
+  it('falls through to the welcome modal when the tour step id is malformed', () => {
+    mockOnboardingStatus('tour_in_progress:not-a-real-step');
+    render(<OnboardingFlow />);
+    // status starts with tour_in_progress: → OnboardingFlow renders TourEngine.
+    // Engine sees stepIndex === -1 and returns null. Welcome modal is also
+    // hidden because status !== null. So nothing renders.
+    expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+  });
+
+  it('advances to the next step when the user clicks Next', async () => {
+    // Pick the last step that doesn't skip under the default mocked state
+    // (authEnabled=false, printerCount=0, no makerworld permission). The
+    // notifications step is permission-agnostic and a good target.
+    const notificationsIndex = TOUR_STEPS.findIndex((s) => s.id === 'notifications');
+    expect(notificationsIndex).toBeGreaterThan(-1);
+    mockOnboardingStatus(statusForStep(notificationsIndex));
+    render(<OnboardingFlow />);
+    const user = userEvent.setup();
+    const buttons = screen.getAllByRole('button');
+    // Buttons in order: Skip (ghost, leftmost), Back, Next/Done.
+    const nextButton = buttons[buttons.length - 1];
+    await user.click(nextButton);
+    expect(setStatusMock).toHaveBeenCalledWith(statusForStep(notificationsIndex + 1));
+  });
+
+  it('marks the tour completed when Next is clicked on the last step', async () => {
+    mockOnboardingStatus(statusForStep(TOUR_STEPS.length - 1));
+    render(<OnboardingFlow />);
+    const user = userEvent.setup();
+    const buttons = screen.getAllByRole('button');
+    const doneButton = buttons[buttons.length - 1];
+    await user.click(doneButton);
+    expect(setStatusMock).toHaveBeenCalledWith('completed_tour');
+  });
+
+  it('goes one step back when the user clicks Back', async () => {
+    // Pick a step that does NOT auto-skip under default mocked state (no
+    // useAuth/useQuery mocks → authEnabled=false, printerCount=0). The
+    // first non-skipIf step in the sequence is `add-spool`.
+    const addSpoolIndex = TOUR_STEPS.findIndex((s) => s.id === 'add-spool');
+    mockOnboardingStatus(statusForStep(addSpoolIndex));
+    render(<OnboardingFlow />);
+    const user = userEvent.setup();
+    const buttons = screen.getAllByRole('button');
+    const backButton = buttons[buttons.length - 2];
+    await user.click(backButton);
+    expect(setStatusMock).toHaveBeenCalledWith(statusForStep(addSpoolIndex - 1));
+  });
+
+  it('disables Back on the first non-skipping step', () => {
+    // auth step would skip if authEnabled, so make sure it doesn't skip
+    // under the default mock. The mock useAuth path returns authEnabled=false
+    // by default, so the auth step renders.
+    mockOnboardingStatus(statusForStep(0));
+    render(<OnboardingFlow />);
+    const buttons = screen.getAllByRole('button');
+    const backButton = buttons[buttons.length - 2];
+    expect(backButton).toBeDisabled();
+  });
+
+  it('persists dismissed when the user clicks Skip', async () => {
+    mockOnboardingStatus(statusForStep(0));
+    render(<OnboardingFlow />);
+    const user = userEvent.setup();
+    const buttons = screen.getAllByRole('button');
+    // Skip is the leftmost button — first in document order in the modal.
+    const skipButton = buttons[0];
+    await user.click(skipButton);
+    expect(setStatusMock).toHaveBeenCalledWith('dismissed');
+  });
+});
+
+describe('TourEngine conditional skipping', () => {
+  it('skips the add-printer step when at least one printer is configured', async () => {
+    const addPrinterIndex = TOUR_STEPS.findIndex((s) => s.id === 'add-printer');
+    mockOnboardingStatus(statusForStep(addPrinterIndex));
+    mockAuth(false);
+    vi.spyOn(ApiClient.api, 'getPrinters').mockResolvedValue([
+      { id: 1, name: 'X1C' } as Printer,
+    ]);
+    render(<OnboardingFlow />);
+    await waitFor(() => {
+      expect(setStatusMock).toHaveBeenCalledWith(statusForStep(addPrinterIndex + 1));
+    });
+  });
+
+  it('marks completed_tour when the last step skips', async () => {
+    // Force a fictitious skipIf on the last step by spying on TOUR_STEPS.
+    const lastIndex = TOUR_STEPS.length - 1;
+    const originalSkipIf = TOUR_STEPS[lastIndex].skipIf;
+    TOUR_STEPS[lastIndex].skipIf = () => true;
+    try {
+      mockOnboardingStatus(statusForStep(lastIndex));
+      mockAuth(false);
+      vi.spyOn(ApiClient.api, 'getPrinters').mockResolvedValue([] as Printer[]);
+      render(<OnboardingFlow />);
+      await waitFor(() => {
+        expect(setStatusMock).toHaveBeenCalledWith('completed_tour');
+      });
+    } finally {
+      TOUR_STEPS[lastIndex].skipIf = originalSkipIf;
+    }
+  });
+});

+ 8 - 1
frontend/src/__tests__/utils.tsx

@@ -10,6 +10,7 @@ import { BrowserRouter } from 'react-router-dom';
 import { ThemeProvider } from '../contexts/ThemeContext';
 import { ToastProvider } from '../contexts/ToastContext';
 import { AuthProvider } from '../contexts/AuthContext';
+import { OnboardingProvider } from '../contexts/OnboardingContext';
 
 // Create a new QueryClient for each test
 function createTestQueryClient() {
@@ -43,7 +44,13 @@ function AllProviders({ children }: AllProvidersProps) {
             AuthProvider". */}
         <AuthProvider>
           <ThemeProvider>
-            <ToastProvider>{children}</ToastProvider>
+            <ToastProvider>
+              {/* OnboardingProvider lives inside AuthProvider in App.tsx so
+                  it can read auth state; tests mirror that. Components using
+                  `useOnboarding()` (Layout's TourLauncher, the onboarding
+                  modals) throw without it. */}
+              <OnboardingProvider>{children}</OnboardingProvider>
+            </ToastProvider>
           </ThemeProvider>
         </AuthProvider>
       </BrowserRouter>

+ 24 - 0
frontend/src/api/client.ts

@@ -3013,6 +3013,20 @@ export interface UserEmailPreferences {
   notify_print_stopped: boolean;
 }
 
+// Onboarding tour state (see docs/onboarding-tour-plan.md Appendix B).
+// `status` is null for users who have not yet seen the welcome modal;
+// `snoozed_until` is ISO 8601 when status === 'snoozed', otherwise null.
+export interface OnboardingResponse {
+  status: string | null;
+  snoozed_until: string | null;
+}
+
+export interface OnboardingUpdate {
+  status: string;
+  /** Required when status === 'snoozed', forbidden otherwise. */
+  snoozed_until?: string | null;
+}
+
 // Auth types
 export interface LoginRequest {
   username: string;
@@ -3440,6 +3454,16 @@ export const api = {
       body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
     }),
 
+  // Onboarding tour state (see docs/onboarding-tour-plan.md Appendix B).
+  // GET returns the current user's tour status; PATCH updates it.
+  getOnboarding: () =>
+    request<OnboardingResponse>('/users/me/onboarding'),
+  updateOnboarding: (data: OnboardingUpdate) =>
+    request<OnboardingResponse>('/users/me/onboarding', {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
+
   // User Email Notifications
   getUserEmailPreferences: () =>
     request<UserEmailPreferences>('/user-notifications/preferences'),

+ 3 - 0
frontend/src/components/Layout.tsx

@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
 import { InstallAppButton } from './InstallAppButton';
+import { TourLauncher } from './onboarding/TourLauncher';
 import { SwitchbarPopover } from './SwitchbarPopover';
 import { useQuery, useQueries } from '@tanstack/react-query';
 import { api, supportApi, pendingUploadsApi, type Permission } from '../api/client';
@@ -647,6 +648,7 @@ export function Layout() {
                   >
                     <NavLink
                       to={to}
+                      data-tour={`sidebar-${id}`}
                       className={({ isActive }) =>
                         `flex items-center ${isSidebarCompact || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
                           isActive
@@ -739,6 +741,7 @@ export function Layout() {
                   </span>
                 )}
                 <InstallAppButton />
+                <TourLauncher />
                 <a
                   href="https://github.com/maziggy/bambuddy"
                   target="_blank"

+ 33 - 0
frontend/src/components/WikiHelpIcon.tsx

@@ -0,0 +1,33 @@
+import { HelpCircle } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+
+interface WikiHelpIconProps {
+  /** Relative wiki path, e.g. `features/queue`. Trailing slash is added. */
+  path: string;
+}
+
+const WIKI_BASE = 'https://wiki.bambuddy.cool';
+
+/**
+ * Small `?` icon button that opens the matching wiki page in a new tab.
+ * Shipped per docs/onboarding-tour-plan.md Appendix G — the original idea was
+ * an in-app iframe modal, but the wiki sets X-Frame-Options DENY and most
+ * MkDocs themes do not behave inside an iframe, so we open a new tab and let
+ * the browser handle it.
+ */
+export function WikiHelpIcon({ path }: WikiHelpIconProps) {
+  const { t } = useTranslation();
+  const label = t('onboarding.helpIcon.openWiki');
+  return (
+    <a
+      href={`${WIKI_BASE}/${path}/`}
+      target="_blank"
+      rel="noopener noreferrer"
+      className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white inline-flex items-center justify-center"
+      title={label}
+      aria-label={label}
+    >
+      <HelpCircle className="w-5 h-5" />
+    </a>
+  );
+}

+ 83 - 0
frontend/src/components/onboarding/AboutModal.tsx

@@ -0,0 +1,83 @@
+import { useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Card, CardContent } from '../Card';
+import { Button } from '../Button';
+import { useOnboarding } from '../../contexts/OnboardingContext';
+import { statusForStep } from './tourSteps';
+import { MascotIcon } from './MascotIcon';
+
+interface AboutModalProps {
+  onClose: () => void;
+}
+
+/**
+ * Phase 0.2 "What Bambuddy is and isn't" modal. Shown after the user clicks
+ * "Start tour" in the welcome modal. The Continue button launches the
+ * step-by-step tour engine by setting status to `tour_in_progress:<first>`;
+ * Skip persists `dismissed` so the welcome flow never re-shows.
+ */
+export function AboutModal({ onClose }: AboutModalProps) {
+  const { t } = useTranslation();
+  const { setStatus } = useOnboarding();
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  const handleDone = async () => {
+    // Launch the step-by-step engine — the engine takes over rendering once
+    // OnboardingFlow sees the tour_in_progress status.
+    await setStatus(statusForStep(0));
+    onClose();
+  };
+
+  const handleSkip = async () => {
+    await setStatus('dismissed');
+    onClose();
+  };
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-[110]"
+      role="dialog"
+      aria-modal="true"
+      aria-labelledby="onboarding-about-title"
+    >
+      <Card className="w-full max-w-lg">
+        <CardContent className="p-6">
+          <div className="text-center mb-4">
+            <MascotIcon pose="walk" className="w-20 h-20 mx-auto mb-3" />
+            <h2 id="onboarding-about-title" className="text-xl font-semibold text-white">
+              {t('onboarding.about.title')}
+            </h2>
+          </div>
+
+          <div className="space-y-4 text-sm">
+            <div>
+              <h3 className="font-semibold text-bambu-green mb-1">{t('onboarding.about.doesTitle')}</h3>
+              <p className="text-bambu-gray">{t('onboarding.about.doesBody')}</p>
+            </div>
+            <div>
+              <h3 className="font-semibold text-bambu-gray-light mb-1">{t('onboarding.about.isntTitle')}</h3>
+              <p className="text-bambu-gray">{t('onboarding.about.isntBody')}</p>
+            </div>
+            <p className="text-xs text-bambu-gray italic">{t('onboarding.about.privacy')}</p>
+          </div>
+
+          <div className="flex gap-2 mt-6">
+            <Button variant="secondary" onClick={handleSkip} className="flex-1">
+              {t('onboarding.button.skipTour')}
+            </Button>
+            <Button onClick={handleDone} className="flex-1">
+              {t('onboarding.button.done')}
+            </Button>
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 41 - 0
frontend/src/components/onboarding/MascotIcon.tsx

@@ -0,0 +1,41 @@
+/**
+ * BB mascot poses, sliced from the character sheet at
+ * screenshots/bb_bambuddy.webp (repo-private reference; not shipped in the
+ * frontend bundle — only the per-pose crops are served). Mapped to tour
+ * moments per Appendix E of docs/onboarding-tour-plan.md:
+ *
+ *   hero    — generic / default
+ *   started — Phase 0.1 welcome (and the sidebar relauncher)
+ *   walk    — Phase 0.2 about and the early "let me show you" steps
+ *   almost  — load-bearing setup steps (Add Printer, Verify Connection)
+ *   allset  — outro / tour-complete state
+ *   help    — informational / need-help steps
+ */
+export type MascotPose = 'hero' | 'started' | 'walk' | 'almost' | 'allset' | 'help';
+
+interface MascotIconProps {
+  pose?: MascotPose;
+  /** CSS class names. Use to control width/height (default w-12 h-12). */
+  className?: string;
+}
+
+const POSE_SRC: Record<MascotPose, string> = {
+  hero: '/img/bb_hero.webp',
+  started: '/img/bb_started.webp',
+  walk: '/img/bb_walk.webp',
+  almost: '/img/bb_almost.webp',
+  allset: '/img/bb_allset.webp',
+  help: '/img/bb_help.webp',
+};
+
+export function MascotIcon({ pose = 'hero', className = 'w-12 h-12' }: MascotIconProps) {
+  return (
+    <img
+      src={POSE_SRC[pose]}
+      alt=""
+      role="presentation"
+      className={`${className} object-contain`}
+      draggable={false}
+    />
+  );
+}

+ 75 - 0
frontend/src/components/onboarding/OnboardingFlow.tsx

@@ -0,0 +1,75 @@
+import { useMemo, useState } from 'react';
+import { useLocation } from 'react-router-dom';
+import { useAuth } from '../../contexts/AuthContext';
+import { useOnboarding } from '../../contexts/OnboardingContext';
+import { WelcomeModal } from './WelcomeModal';
+import { AboutModal } from './AboutModal';
+import { TourEngine } from './TourEngine';
+
+// Routes where the onboarding overlay must never render. Setup is the
+// fresh-install auth bootstrap and must be free of overlays; login is
+// pre-auth; the SpoolBuddy kiosk + standalone camera/overlay pages each have
+// their own layout and would be visually broken by a modal slapped over them.
+const SUPPRESS_PREFIXES = ['/spoolbuddy', '/camera/', '/overlay/'];
+const SUPPRESS_EXACT = new Set(['/setup', '/login']);
+
+type Phase = 'welcome' | 'about' | 'done';
+
+/**
+ * Top-level driver for the onboarding surface. Three things can render
+ * depending on backend state + local phase:
+ *
+ *   1. The step-by-step tour overlay (when status starts with
+ *      `tour_in_progress:`)
+ *   2. The Phase 0 welcome modal (when status is null OR a snooze has
+ *      elapsed)
+ *   3. The Phase 0 about modal (after the user clicks "Start tour" in the
+ *      welcome modal — tracked via local phase state)
+ *
+ * Hidden in every other backend state (dismissed / completed_tour /
+ * dismissed_at_migration).
+ */
+export function OnboardingFlow() {
+  const { status, snoozedUntil, isLoaded, loadFailed } = useOnboarding();
+  const { requiresSetup } = useAuth();
+  const location = useLocation();
+  const [phase, setPhase] = useState<Phase>('welcome');
+
+  const onSuppressedRoute = useMemo(() => {
+    if (SUPPRESS_EXACT.has(location.pathname)) return true;
+    return SUPPRESS_PREFIXES.some((p) => location.pathname.startsWith(p));
+  }, [location.pathname]);
+
+  const shouldShowPhase0 = useMemo(() => {
+    if (!isLoaded || loadFailed) return false;
+    if (status === null) return true;
+    if (status === 'snoozed' && snoozedUntil) {
+      const snoozeMs = new Date(snoozedUntil).getTime();
+      if (!Number.isNaN(snoozeMs) && snoozeMs <= Date.now()) return true;
+    }
+    return false;
+  }, [status, snoozedUntil, isLoaded, loadFailed]);
+
+  // Fresh install is still on the /setup flow — don't pop anything yet.
+  // Standalone routes (login / kiosk / camera / overlay) also stay clear.
+  if (requiresSetup || onSuppressedRoute) return null;
+
+  // Tour engine takes priority — once `tour_in_progress:<step>` is the live
+  // state, the engine owns the screen until the user finishes or skips.
+  if (status?.startsWith('tour_in_progress:')) {
+    return <TourEngine />;
+  }
+
+  if (!shouldShowPhase0 || phase === 'done') return null;
+
+  if (phase === 'welcome') {
+    return (
+      <WelcomeModal
+        onStartTour={() => setPhase('about')}
+        onClose={() => setPhase('done')}
+      />
+    );
+  }
+
+  return <AboutModal onClose={() => setPhase('done')} />;
+}

+ 271 - 0
frontend/src/components/onboarding/TourEngine.tsx

@@ -0,0 +1,271 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useLocation, useNavigate } from 'react-router-dom';
+import { useQuery } from '@tanstack/react-query';
+import { Card, CardContent } from '../Card';
+import { Button } from '../Button';
+import { api } from '../../api/client';
+import { useAuth } from '../../contexts/AuthContext';
+import { useOnboarding } from '../../contexts/OnboardingContext';
+import { TourSpotlight } from './TourSpotlight';
+import { MascotIcon } from './MascotIcon';
+import { TOUR_STEPS, statusForStep, stepIndexFromStatus } from './tourSteps';
+import type { TourStepContext } from './tourSteps';
+
+const ANCHOR_POLL_MS = 100;
+const ANCHOR_TIMEOUT_MS = 3000;
+const MODAL_MARGIN = 16;
+const MODAL_WIDTH = 360;
+
+interface ModalPosition {
+  top: number;
+  left: number;
+}
+
+/**
+ * Decide where to place the step modal relative to the anchor. Prefers below
+ * the anchor; if there isn't enough room, flips above; for sidebar anchors
+ * (anchor on the far left), flips to the right.
+ */
+function computeModalPosition(anchorRect: DOMRect | null): ModalPosition {
+  const viewportH = window.innerHeight;
+  const viewportW = window.innerWidth;
+  // No anchor → centre the modal
+  if (!anchorRect) {
+    return {
+      top: Math.max(MODAL_MARGIN, viewportH / 2 - 160),
+      left: Math.max(MODAL_MARGIN, viewportW / 2 - MODAL_WIDTH / 2),
+    };
+  }
+
+  // Sidebar anchor heuristic — the live sidebar lives in the leftmost ~260px.
+  // Put the modal to the right of the anchor in that case.
+  if (anchorRect.right < 280) {
+    return {
+      top: Math.min(
+        Math.max(MODAL_MARGIN, anchorRect.top),
+        viewportH - 320,
+      ),
+      left: anchorRect.right + MODAL_MARGIN,
+    };
+  }
+
+  const roomBelow = viewportH - anchorRect.bottom;
+  const placeBelow = roomBelow > 280;
+  const top = placeBelow
+    ? anchorRect.bottom + MODAL_MARGIN
+    : Math.max(MODAL_MARGIN, anchorRect.top - 280);
+  const left = Math.min(
+    Math.max(MODAL_MARGIN, anchorRect.left + anchorRect.width / 2 - MODAL_WIDTH / 2),
+    viewportW - MODAL_WIDTH - MODAL_MARGIN,
+  );
+  return { top, left };
+}
+
+/**
+ * The step-by-step tour overlay. Activated when `OnboardingContext.status`
+ * starts with `tour_in_progress:`. Walks the user through the steps defined
+ * in tourSteps.ts, navigating between routes as needed and persisting the
+ * current step back to the backend after each Back/Next.
+ *
+ * The anchor lookup retries for up to ANCHOR_TIMEOUT_MS — pages need a beat
+ * to render after navigation. If the anchor still doesn't exist after the
+ * timeout, the step renders without a spotlight (modal centred) so the user
+ * is never stuck.
+ */
+export function TourEngine() {
+  const { t } = useTranslation();
+  const navigate = useNavigate();
+  const location = useLocation();
+  const { status, setStatus } = useOnboarding();
+  const { authEnabled, hasPermission } = useAuth();
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+    staleTime: 30_000,
+  });
+  const [anchorEl, setAnchorEl] = useState<Element | null>(null);
+  const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
+  const stepIndex = useMemo(() => stepIndexFromStatus(status), [status]);
+  const step = stepIndex >= 0 ? TOUR_STEPS[stepIndex] : null;
+  const stepStartedAtRef = useRef<number>(0);
+
+  const skipContext = useMemo<TourStepContext>(
+    () => ({
+      authEnabled,
+      printerCount: printers?.length ?? 0,
+      // Cast through any: useAuth.hasPermission is typed as
+      // (p: Permission) => boolean; the tour calls it with raw strings since
+      // it doesn't import the Permission enum. The runtime check is the same.
+      hasPermission: (perm: string) => hasPermission(perm as unknown as never),
+    }),
+    [authEnabled, printers, hasPermission],
+  );
+
+  // Auto-advance past any step whose `skipIf` evaluates true under the
+  // current app state — eg. "Lock the front door" is silly when auth is
+  // already on, and "Add your first printer" is silly when one exists. If
+  // we land on the last step and it skips, we mark the tour completed so
+  // we don't loop. Guarded against re-entrancy by gating on `step` itself.
+  useEffect(() => {
+    if (!step) return;
+    if (!step.skipIf?.(skipContext)) return;
+    if (stepIndex >= TOUR_STEPS.length - 1) {
+      setStatus('completed_tour');
+    } else {
+      setStatus(statusForStep(stepIndex + 1));
+    }
+  }, [step, stepIndex, skipContext, setStatus]);
+
+  // Navigate to the step's route before we try to find its anchor. We compare
+  // pathname + search separately because the existing app uses `?tab=users`
+  // and similar query-string deep-links; navigate() updates both.
+  useEffect(() => {
+    if (!step) return;
+    if (!step.route) return;
+    const [pathname, search = ''] = step.route.split('?');
+    const currentSearch = location.search.replace(/^\?/, '');
+    if (location.pathname !== pathname || currentSearch !== search) {
+      navigate(step.route);
+    }
+  }, [step, location.pathname, location.search, navigate]);
+
+  // Anchor lookup — poll until the element exists or we give up.
+  useEffect(() => {
+    if (!step) {
+      setAnchorEl(null);
+      setAnchorRect(null);
+      return;
+    }
+    if (!step.anchor) {
+      // Outro-style step — no anchor, centre the modal.
+      setAnchorEl(null);
+      setAnchorRect(null);
+      return;
+    }
+    stepStartedAtRef.current = Date.now();
+    let cancelled = false;
+
+    const tryFind = () => {
+      if (cancelled) return;
+      const el = document.querySelector(step.anchor!);
+      if (el) {
+        setAnchorEl(el);
+        setAnchorRect(el.getBoundingClientRect());
+        el.scrollIntoView({ behavior: 'smooth', block: 'center' });
+        return;
+      }
+      // The use of Date.now to bound polling is deliberate — without a hard
+      // cap we'd spin forever if the anchor selector is wrong or the page
+      // doesn't render the element under the current state.
+      if (Date.now() - stepStartedAtRef.current < ANCHOR_TIMEOUT_MS) {
+        setTimeout(tryFind, ANCHOR_POLL_MS);
+      } else {
+        setAnchorEl(null);
+        setAnchorRect(null);
+      }
+    };
+    tryFind();
+
+    return () => {
+      cancelled = true;
+    };
+  }, [step, location.pathname, location.search]);
+
+  // Recompute anchor rect on resize/scroll so the modal follows the anchor.
+  useEffect(() => {
+    if (!anchorEl) return;
+    const update = () => setAnchorRect(anchorEl.getBoundingClientRect());
+    window.addEventListener('resize', update);
+    window.addEventListener('scroll', update, true);
+    return () => {
+      window.removeEventListener('resize', update);
+      window.removeEventListener('scroll', update, true);
+    };
+  }, [anchorEl]);
+
+  const handleBack = useCallback(() => {
+    if (stepIndex <= 0) return;
+    setStatus(statusForStep(stepIndex - 1));
+  }, [stepIndex, setStatus]);
+
+  const handleNext = useCallback(() => {
+    if (stepIndex < 0) return;
+    if (stepIndex >= TOUR_STEPS.length - 1) {
+      setStatus('completed_tour');
+      return;
+    }
+    setStatus(statusForStep(stepIndex + 1));
+  }, [stepIndex, setStatus]);
+
+  const handleSkip = useCallback(() => {
+    setStatus('dismissed');
+  }, [setStatus]);
+
+  // Escape key skips the tour.
+  useEffect(() => {
+    if (!step) return;
+    const onKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') handleSkip();
+    };
+    window.addEventListener('keydown', onKeyDown);
+    return () => window.removeEventListener('keydown', onKeyDown);
+  }, [step, handleSkip]);
+
+  if (!step) return null;
+  // Hide the modal entirely while the skip-effect catches up — without this
+  // gate the user briefly sees the skipped step's content before the effect
+  // setStatuses past it.
+  if (step.skipIf?.(skipContext)) return null;
+
+  const modalPos = computeModalPosition(anchorRect);
+  const isLastStep = stepIndex === TOUR_STEPS.length - 1;
+  const isFirstStep = stepIndex === 0;
+
+  return (
+    <>
+      <TourSpotlight anchor={anchorEl} />
+      <Card
+        role="dialog"
+        aria-modal="true"
+        aria-labelledby="tour-step-title"
+        className="fixed z-[110] shadow-2xl"
+        style={{
+          top: modalPos.top,
+          left: modalPos.left,
+          width: MODAL_WIDTH,
+        }}
+      >
+        <CardContent className="p-5">
+          <div className="flex items-center gap-3 mb-3">
+            <MascotIcon pose={step.pose ?? 'hero'} className="w-12 h-12 flex-shrink-0" />
+            <div className="text-xs text-bambu-gray">
+              {stepIndex + 1} / {TOUR_STEPS.length}
+            </div>
+          </div>
+          <h3 id="tour-step-title" className="text-lg font-semibold text-white mb-2">
+            {t(step.titleKey)}
+          </h3>
+          <p className="text-sm text-bambu-gray mb-5">{t(step.bodyKey)}</p>
+          <div className="flex items-center justify-between gap-2">
+            <Button variant="ghost" onClick={handleSkip} className="text-xs">
+              {t('onboarding.button.skipTour')}
+            </Button>
+            <div className="flex gap-2">
+              <Button
+                variant="secondary"
+                onClick={handleBack}
+                disabled={isFirstStep}
+              >
+                {t('onboarding.button.back')}
+              </Button>
+              <Button onClick={handleNext}>
+                {isLastStep ? t('onboarding.button.done') : t('onboarding.button.next')}
+              </Button>
+            </div>
+          </div>
+        </CardContent>
+      </Card>
+    </>
+  );
+}

+ 28 - 0
frontend/src/components/onboarding/TourLauncher.tsx

@@ -0,0 +1,28 @@
+import { useTranslation } from 'react-i18next';
+import { useOnboarding } from '../../contexts/OnboardingContext';
+import { TOUR_STEPS, statusForStep } from './tourSteps';
+import { MascotIcon } from './MascotIcon';
+
+/**
+ * BB icon rendered in the sidebar footer that relaunches the tour from step
+ * 0. Consumes the `[data-tour="help-icon"]` selector reserved in the anchor
+ * PR. Acts as the rehome target for `onboarding.outro.rehome` — the user
+ * can always find BB here even after dismissing the tour.
+ */
+export function TourLauncher() {
+  const { t } = useTranslation();
+  const { setStatus } = useOnboarding();
+
+  return (
+    <button
+      data-tour="help-icon"
+      onClick={() => setStatus(statusForStep(0))}
+      className="p-1 rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
+      title={t('onboarding.outro.rehome')}
+      aria-label={t('onboarding.outro.rehome')}
+      disabled={TOUR_STEPS.length === 0}
+    >
+      <MascotIcon pose="started" className="w-7 h-7" />
+    </button>
+  );
+}

+ 65 - 0
frontend/src/components/onboarding/TourSpotlight.tsx

@@ -0,0 +1,65 @@
+import { useEffect, useState } from 'react';
+
+interface TourSpotlightProps {
+  /** Anchor element to highlight. Null fades the spotlight off — the dimmed
+   *  backdrop is preserved for the no-anchor outro step. */
+  anchor: Element | null;
+}
+
+const SPOTLIGHT_PADDING = 8;
+
+/**
+ * Dimmed-page backdrop with a transparent cutout around the anchor element.
+ *
+ * Uses `box-shadow: 0 0 0 9999px` to paint the dark area around a transparent
+ * div — far cheaper than an SVG mask and animates smoothly. `pointer-events:
+ * none` so clicks pass through to the underlying page (we do not gate the
+ * user; the spotlight is a visual cue, not a wall).
+ *
+ * Recomputes the anchor rect on `resize` and `scroll` so the cutout follows
+ * the element through layout changes.
+ */
+export function TourSpotlight({ anchor }: TourSpotlightProps) {
+  const [rect, setRect] = useState<DOMRect | null>(null);
+
+  useEffect(() => {
+    if (!anchor) {
+      setRect(null);
+      return;
+    }
+
+    const update = () => setRect(anchor.getBoundingClientRect());
+    update();
+
+    window.addEventListener('resize', update);
+    window.addEventListener('scroll', update, true);
+    return () => {
+      window.removeEventListener('resize', update);
+      window.removeEventListener('scroll', update, true);
+    };
+  }, [anchor]);
+
+  if (!rect) {
+    return (
+      <div
+        className="fixed inset-0 bg-black/60 z-[100] pointer-events-none"
+        aria-hidden="true"
+      />
+    );
+  }
+
+  return (
+    <div
+      data-testid="tour-spotlight"
+      aria-hidden="true"
+      className="fixed rounded-lg z-[100] pointer-events-none transition-all duration-200"
+      style={{
+        top: rect.top - SPOTLIGHT_PADDING,
+        left: rect.left - SPOTLIGHT_PADDING,
+        width: rect.width + SPOTLIGHT_PADDING * 2,
+        height: rect.height + SPOTLIGHT_PADDING * 2,
+        boxShadow: '0 0 0 9999px rgba(0, 0, 0, 0.6)',
+      }}
+    />
+  );
+}

+ 73 - 0
frontend/src/components/onboarding/WelcomeModal.tsx

@@ -0,0 +1,73 @@
+import { useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Card, CardContent } from '../Card';
+import { Button } from '../Button';
+import { useOnboarding } from '../../contexts/OnboardingContext';
+import { MascotIcon } from './MascotIcon';
+
+const SNOOZE_DAYS = 7;
+
+interface WelcomeModalProps {
+  onStartTour: () => void;
+  onClose: () => void;
+}
+
+/**
+ * Phase 0.1 welcome modal. Pops once for new users; the three buttons map to
+ * the three branches in docs/onboarding-tour-plan.md:
+ *   - Start tour    → advance to AboutModal (status update happens at the end)
+ *   - I'm experienced → persist `dismissed`
+ *   - Remind me later → persist `snoozed` + a 7-day timestamp
+ *
+ */
+export function WelcomeModal({ onStartTour, onClose }: WelcomeModalProps) {
+  const { t } = useTranslation();
+  const { setStatus } = useOnboarding();
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  const handleExperienced = async () => {
+    await setStatus('dismissed');
+    onClose();
+  };
+
+  const handleSnooze = async () => {
+    const snoozeUntil = new Date(Date.now() + SNOOZE_DAYS * 24 * 60 * 60 * 1000).toISOString();
+    await setStatus('snoozed', snoozeUntil);
+    onClose();
+  };
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-[110]"
+      role="dialog"
+      aria-modal="true"
+      aria-labelledby="onboarding-welcome-title"
+    >
+      <Card className="w-full max-w-md">
+        <CardContent className="p-6 text-center">
+          <MascotIcon pose="started" className="w-24 h-24 mx-auto mb-3" />
+          <h2 id="onboarding-welcome-title" className="text-xl font-semibold text-white">
+            {t('onboarding.welcome.title')}
+          </h2>
+          <p className="text-sm text-bambu-gray mt-2 mb-6">{t('onboarding.welcome.body')}</p>
+          <div className="flex flex-col gap-2">
+            <Button onClick={onStartTour}>{t('onboarding.welcome.startTour')}</Button>
+            <Button variant="secondary" onClick={handleExperienced}>
+              {t('onboarding.welcome.experienced')}
+            </Button>
+            <Button variant="ghost" onClick={handleSnooze}>
+              {t('onboarding.button.remindLater')}
+            </Button>
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 276 - 0
frontend/src/components/onboarding/tourSteps.ts

@@ -0,0 +1,276 @@
+/**
+ * Step definitions for the onboarding tour engine.
+ *
+ * Each step points the engine at a `data-tour="..."` anchor (mounted by the
+ * earlier anchor PR) and provides the i18n keys for its title + body. Steps
+ * also carry an optional `route` so the engine can navigate before the anchor
+ * could possibly resolve.
+ *
+ * See docs/onboarding-tour-plan.md for the source-of-truth step layout. This
+ * implementation ships a subset (auth → add printer → sidebar overview →
+ * outro) — the deeper Phase 1.3 / 1.4 steps need a real printer wired in
+ * before their anchors can render, so they're owned by a follow-up PR that
+ * adds conditional skipping.
+ */
+export interface TourStepContext {
+  /** True when Bambuddy is running with authentication on. */
+  authEnabled: boolean;
+  /** Total printers configured on this instance. */
+  printerCount: number;
+  /** Permission check for skipping permission-gated steps (e.g. MakerWorld). */
+  hasPermission: (permission: string) => boolean;
+}
+
+import type { MascotPose } from './MascotIcon';
+
+export interface TourStep {
+  /** Stable identifier — persisted as `tour_in_progress:<id>` in the backend. */
+  id: string;
+  /** CSS selector for the anchor. Null means a centred modal with no highlight. */
+  anchor: string | null;
+  /** Path (with optional ?query) to navigate to before the step renders. */
+  route?: string;
+  /** i18n key for the step title. */
+  titleKey: string;
+  /** i18n key for the step body copy. */
+  bodyKey: string;
+  /** BB pose to display in the step modal. */
+  pose?: MascotPose;
+  /** Returns true when the step should be auto-skipped under the current
+   *  app state. Engine advances past it without rendering anything. */
+  skipIf?: (ctx: TourStepContext) => boolean;
+}
+
+export const TOUR_STEPS: TourStep[] = [
+  // Phase 1.1 "Lock the front door first" used to live here. Removed — the
+  // /setup page already prompts for the auth choice on fresh installs, and
+  // users who deliberately chose no-auth should not be nudged to enable it.
+  // The plan's Step 1.1 content remains for design reference only.
+  {
+    id: 'add-printer',
+    anchor: '[data-tour="add-printer-button"]',
+    route: '/',
+    titleKey: 'onboarding.addPrinter.title',
+    bodyKey: 'onboarding.addPrinter.body',
+    pose: 'almost',
+    skipIf: (ctx) => ctx.printerCount > 0,
+  },
+  {
+    id: 'verify-connection',
+    anchor: '[data-tour="printer-status-pill"]',
+    route: '/',
+    titleKey: 'onboarding.verifyConnection.title',
+    bodyKey: 'onboarding.verifyConnection.allGreen',
+    pose: 'almost',
+    // No printer? Nothing to verify.
+    skipIf: (ctx) => ctx.printerCount === 0,
+  },
+  // Phase 1.4 — card sub-tour. Each step highlights one part of the printer
+  // card. All five share the same title and skip when no printer exists.
+  {
+    id: 'card-status',
+    anchor: '[data-tour="printer-status-row"]',
+    route: '/',
+    titleKey: 'onboarding.tourCard.title',
+    bodyKey: 'onboarding.tourCard.status',
+    pose: 'walk',
+    skipIf: (ctx) => ctx.printerCount === 0,
+  },
+  {
+    id: 'card-ams',
+    anchor: '[data-tour="printer-ams-row"]',
+    route: '/',
+    titleKey: 'onboarding.tourCard.title',
+    bodyKey: 'onboarding.tourCard.ams',
+    pose: 'walk',
+    skipIf: (ctx) => ctx.printerCount === 0,
+  },
+  {
+    id: 'card-camera',
+    anchor: '[data-tour="printer-camera"]',
+    route: '/',
+    titleKey: 'onboarding.tourCard.title',
+    bodyKey: 'onboarding.tourCard.camera',
+    pose: 'walk',
+    skipIf: (ctx) => ctx.printerCount === 0,
+  },
+  {
+    id: 'card-controls',
+    anchor: '[data-tour="printer-controls"]',
+    route: '/',
+    titleKey: 'onboarding.tourCard.title',
+    bodyKey: 'onboarding.tourCard.controls',
+    pose: 'walk',
+    skipIf: (ctx) => ctx.printerCount === 0,
+  },
+  {
+    id: 'card-customize',
+    anchor: '[data-tour="printer-customize"]',
+    route: '/',
+    titleKey: 'onboarding.tourCard.title',
+    bodyKey: 'onboarding.tourCard.customize',
+    pose: 'help',
+    skipIf: (ctx) => ctx.printerCount === 0,
+  },
+  {
+    id: 'add-spool',
+    anchor: '[data-tour="add-spool-button"]',
+    route: '/inventory',
+    titleKey: 'onboarding.addSpool.title',
+    bodyKey: 'onboarding.addSpool.intro',
+    pose: 'help',
+  },
+  {
+    id: 'bambu-cloud',
+    anchor: '[data-tour="bambu-cloud-sync"]',
+    route: '/profiles',
+    titleKey: 'onboarding.bambuCloud.title',
+    bodyKey: 'onboarding.bambuCloud.body',
+    pose: 'walk',
+  },
+  {
+    id: 'sidebar-queue',
+    anchor: '[data-tour="sidebar-queue"]',
+    titleKey: 'onboarding.sidebar.title',
+    bodyKey: 'onboarding.sidebar.queue',
+    pose: 'walk',
+  },
+  {
+    id: 'sidebar-archives',
+    anchor: '[data-tour="sidebar-archives"]',
+    titleKey: 'onboarding.sidebar.title',
+    bodyKey: 'onboarding.sidebar.archives',
+    pose: 'walk',
+  },
+  {
+    id: 'sidebar-stats',
+    anchor: '[data-tour="sidebar-stats"]',
+    titleKey: 'onboarding.sidebar.title',
+    bodyKey: 'onboarding.sidebar.stats',
+    pose: 'walk',
+  },
+  {
+    id: 'sidebar-maintenance',
+    anchor: '[data-tour="sidebar-maintenance"]',
+    titleKey: 'onboarding.sidebar.title',
+    bodyKey: 'onboarding.sidebar.maintenance',
+    pose: 'walk',
+  },
+  {
+    id: 'sidebar-files',
+    anchor: '[data-tour="sidebar-files"]',
+    titleKey: 'onboarding.sidebar.title',
+    bodyKey: 'onboarding.sidebar.files',
+    pose: 'walk',
+  },
+  {
+    id: 'sidebar-projects',
+    anchor: '[data-tour="sidebar-projects"]',
+    titleKey: 'onboarding.sidebar.title',
+    bodyKey: 'onboarding.sidebar.projects',
+    pose: 'walk',
+  },
+  // Phase 3 — power features behind the implicit "Interested?" gate (which
+  // today is just the Skip button). Each step targets a Settings sub-card
+  // and navigates to the right tab.
+  {
+    id: 'vp',
+    anchor: '[data-tour="vp-card"]',
+    route: '/settings?tab=virtual-printer',
+    titleKey: 'onboarding.vp.title',
+    bodyKey: 'onboarding.vp.body',
+    pose: 'help',
+  },
+  {
+    id: 'slicer-api',
+    anchor: '[data-tour="slicer-api-card"]',
+    route: '/settings?tab=queue',
+    titleKey: 'onboarding.slicerApi.title',
+    bodyKey: 'onboarding.slicerApi.body',
+    pose: 'walk',
+  },
+  {
+    id: 'makerworld',
+    anchor: '[data-tour="sidebar-makerworld"]',
+    titleKey: 'onboarding.makerworld.title',
+    bodyKey: 'onboarding.makerworld.body',
+    pose: 'walk',
+    // Permission-gated nav entry — if the user does not have makerworld:view,
+    // the sidebar item itself is hidden and the anchor would not resolve.
+    skipIf: (ctx) => !ctx.hasPermission('makerworld:view'),
+  },
+  {
+    id: 'obico',
+    anchor: '[data-tour="obico-card"]',
+    route: '/settings?tab=failure-detection',
+    titleKey: 'onboarding.obico.title',
+    bodyKey: 'onboarding.obico.body',
+    pose: 'help',
+  },
+  {
+    id: 'integrations',
+    anchor: '[data-tour="integrations-card"]',
+    route: '/settings?tab=network',
+    titleKey: 'onboarding.integrations.title',
+    bodyKey: 'onboarding.integrations.body',
+    pose: 'walk',
+  },
+  {
+    id: 'notifications',
+    anchor: '[data-tour="sidebar-notifications"]',
+    titleKey: 'onboarding.notifications.title',
+    bodyKey: 'onboarding.notifications.body',
+    pose: 'walk',
+  },
+  // Phase 4 — multi-user setup. Only relevant when auth is on.
+  {
+    id: 'users',
+    anchor: '[data-tour="add-user-button"]',
+    route: '/settings?tab=users',
+    titleKey: 'onboarding.users.title',
+    bodyKey: 'onboarding.users.body',
+    pose: 'help',
+    skipIf: (ctx) => !ctx.authEnabled,
+  },
+  {
+    id: 'groups',
+    anchor: '[data-tour="groups-section"]',
+    route: '/settings?tab=users',
+    titleKey: 'onboarding.groups.title',
+    bodyKey: 'onboarding.groups.body',
+    pose: 'walk',
+    skipIf: (ctx) => !ctx.authEnabled,
+  },
+  {
+    id: 'sso',
+    anchor: '[data-tour="sso-section"]',
+    route: '/settings?tab=users',
+    titleKey: 'onboarding.sso.title',
+    bodyKey: 'onboarding.sso.body',
+    pose: 'help',
+    skipIf: (ctx) => !ctx.authEnabled,
+  },
+  {
+    id: 'outro',
+    anchor: null,
+    titleKey: 'onboarding.outro.title',
+    bodyKey: 'onboarding.outro.system',
+    pose: 'allset',
+  },
+];
+
+/** Returns the step index for a status string like `tour_in_progress:<id>`. */
+export function stepIndexFromStatus(status: string | null): number {
+  if (!status) return -1;
+  const prefix = 'tour_in_progress:';
+  if (!status.startsWith(prefix)) return -1;
+  const id = status.slice(prefix.length);
+  return TOUR_STEPS.findIndex((s) => s.id === id);
+}
+
+/** Builds the status string for a given step index. */
+export function statusForStep(index: number): string {
+  const step = TOUR_STEPS[index];
+  if (!step) throw new Error(`Invalid tour step index: ${index}`);
+  return `tour_in_progress:${step.id}`;
+}

+ 120 - 0
frontend/src/contexts/OnboardingContext.tsx

@@ -0,0 +1,120 @@
+import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
+import { api } from '../api/client';
+import { useAuth } from './AuthContext';
+
+interface OnboardingState {
+  status: string | null;
+  snoozedUntil: string | null;
+}
+
+interface OnboardingContextType extends OnboardingState {
+  /** True once the initial GET (or localStorage read) has settled. */
+  isLoaded: boolean;
+  /** True if the initial GET errored. The UI should not pop the welcome
+   *  modal in this case — we cannot distinguish "new user" from "backend
+   *  unreachable", so showing the welcome would be wrong. */
+  loadFailed: boolean;
+  /** Persist a new status. PATCHes to backend when auth is on, writes
+   *  localStorage when auth is off. Silent on failure — onboarding is
+   *  non-critical so we do not block the UI. */
+  setStatus: (status: string, snoozedUntil?: string | null) => Promise<void>;
+}
+
+const OnboardingContext = createContext<OnboardingContextType | undefined>(undefined);
+
+const LOCALSTORAGE_STATUS = 'bambuddy.onboarding_status';
+const LOCALSTORAGE_SNOOZE = 'bambuddy.onboarding_snoozed_until';
+
+export function OnboardingProvider({ children }: { children: React.ReactNode }) {
+  const { authEnabled, user, loading: authLoading } = useAuth();
+  const [state, setState] = useState<OnboardingState>({ status: null, snoozedUntil: null });
+  const [isLoaded, setIsLoaded] = useState(false);
+  const [loadFailed, setLoadFailed] = useState(false);
+
+  useEffect(() => {
+    if (authLoading) return;
+
+    let cancelled = false;
+
+    if (authEnabled) {
+      if (!user) {
+        // No active session — there is no "me" to query. Mark loaded so the
+        // rest of the app does not block, but leave loadFailed so the welcome
+        // modal stays hidden until the user logs in.
+        setIsLoaded(true);
+        setLoadFailed(true);
+        return;
+      }
+      api.getOnboarding()
+        .then((data) => {
+          if (cancelled) return;
+          setState({
+            status: data.status ?? null,
+            snoozedUntil: data.snoozed_until ?? null,
+          });
+          setLoadFailed(false);
+        })
+        .catch(() => {
+          if (cancelled) return;
+          setLoadFailed(true);
+        })
+        .finally(() => {
+          if (cancelled) return;
+          setIsLoaded(true);
+        });
+    } else {
+      const status = localStorage.getItem(LOCALSTORAGE_STATUS);
+      const snoozedUntil = localStorage.getItem(LOCALSTORAGE_SNOOZE);
+      setState({ status, snoozedUntil });
+      setLoadFailed(false);
+      setIsLoaded(true);
+    }
+
+    return () => {
+      cancelled = true;
+    };
+  }, [authLoading, authEnabled, user]);
+
+  const setStatus = useCallback(
+    async (status: string, snoozedUntil?: string | null) => {
+      if (authEnabled && user) {
+        try {
+          const body: { status: string; snoozed_until?: string | null } = { status };
+          if (status === 'snoozed') body.snoozed_until = snoozedUntil ?? null;
+          const data = await api.updateOnboarding(body);
+          setState({
+            status: data.status ?? null,
+            snoozedUntil: data.snoozed_until ?? null,
+          });
+        } catch {
+          // Persistence failed — keep the UI responsive by updating local state
+          // anyway so the modal closes. The next page load will re-GET and the
+          // real backend state will surface.
+          setState({ status, snoozedUntil: snoozedUntil ?? null });
+        }
+      } else {
+        localStorage.setItem(LOCALSTORAGE_STATUS, status);
+        if (status === 'snoozed' && snoozedUntil) {
+          localStorage.setItem(LOCALSTORAGE_SNOOZE, snoozedUntil);
+        } else {
+          localStorage.removeItem(LOCALSTORAGE_SNOOZE);
+        }
+        setState({ status, snoozedUntil: status === 'snoozed' ? snoozedUntil ?? null : null });
+      }
+    },
+    [authEnabled, user],
+  );
+
+  const value = useMemo<OnboardingContextType>(
+    () => ({ ...state, isLoaded, loadFailed, setStatus }),
+    [state, isLoaded, loadFailed, setStatus],
+  );
+
+  return <OnboardingContext.Provider value={value}>{children}</OnboardingContext.Provider>;
+}
+
+export function useOnboarding(): OnboardingContextType {
+  const ctx = useContext(OnboardingContext);
+  if (!ctx) throw new Error('useOnboarding must be used within OnboardingProvider');
+  return ctx;
+}

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

@@ -6235,4 +6235,196 @@ export default {
     noReadAccess: 'Sie haben keine Berechtigung, Bestandsprognosen anzuzeigen.',
     noWriteAccess: 'Sie haben keine Berechtigung, Prognoseeinstellungen zu ändern.',
   },
+
+  // Onboarding-Tour
+  onboarding: {
+    button: {
+      next: 'Weiter',
+      back: 'Zurück',
+      skip: 'Überspringen',
+      skipTour: 'Tour überspringen',
+      done: 'Fertig',
+      interested: 'Mehr erfahren',
+      notInterested: 'Später anzeigen',
+      remindLater: 'Später erinnern',
+    },
+    welcome: {
+      title: 'Willkommen bei Bambuddy',
+      body: 'Bambuddy ersetzt die Bambu-Lab-Cloud durch ein lokales Dashboard. Ihre Daten, Drucke, Spulen und Timelapses bleiben auf Ihrer Hardware. Möchten Sie eine fünfminütige Tour?',
+      startTour: 'Tour starten',
+      experienced: 'Ich kenne mich aus',
+    },
+    about: {
+      title: 'Was Bambuddy ist — und was nicht',
+      doesTitle: 'Was Bambuddy macht',
+      doesBody: 'Ersetzt die Bambu-Cloud lokal, verfolgt AMS und Filamentbestand per RFID, betreibt eine Druckwarteschlange, archiviert jeden abgeschlossenen Druck, stellt Ihrem Slicer einen virtuellen Drucker bereit, unterstützt mehrere Benutzer und bringt erstklassige Home-Assistant- und Tailscale-Integration mit.',
+      isntTitle: 'Was Bambuddy nicht ist',
+      isntBody: 'Kein Slicer (Bambuddy übergibt an BambuStudio oder OrcaSlicer), kein Cloud-Dienst, kein Firmware-Werkzeug, keine Klipper-Oberfläche.',
+      privacy: 'Keine Telemetrie. Keine Konten. bambuddy.cool liefert nur die Dokumentation aus.',
+    },
+    auth: {
+      title: 'Zuerst die Haustür abschließen',
+      body: 'Wenn jemand anderes in Ihrem Netzwerk, in Ihrem Tailnet oder über Ihren Reverse-Proxy diese URL erreichen kann, schalten Sie die Anmeldung jetzt ein. Passwörter, OIDC, SAML und MFA sind eingebaut.',
+      severity: 'Bambuddy kann Ihre Drucker steuern, Dateien verwalten und Kameras lesen — behandeln Sie die URL wie ein Admin-Panel.',
+      enableNow: 'Anmeldung jetzt aktivieren',
+      later: 'Später — diese URL ist privat',
+    },
+    addPrinter: {
+      title: 'Fügen Sie Ihren ersten Drucker hinzu',
+      body: 'Sie brauchen drei Dinge: das Druckermodell, seine IP-Adresse im Netzwerk und seinen Zugangscode.',
+      modelLabel: 'Modell',
+      modelHint: 'Über die Erkennung automatisch ermittelt, oder manuell wählen, falls Bambuddy den Drucker nicht sieht.',
+      ipLabel: 'IP-Adresse',
+      ipHint: 'Auf dem Drucker-Display unter Einstellungen → Netzwerk angezeigt. Eine DHCP-Reservierung im Router hält sie stabil.',
+      codeLabel: 'Zugangscode',
+      codeHint: 'Wird auf dem Drucker-Display angezeigt; der Pfad ist modellabhängig — siehe Popover unten.',
+      lanModeWarning: 'Aktivieren Sie den LAN-Only-Modus auf dem Drucker (X1-, H2- und P2S-Familie). Ohne ihn bleiben die MQTT- und FTP-Ports gesperrt.',
+      devModeWarning: 'Aktivieren Sie den Entwicklermodus auf dem Drucker-Display — bei den meisten Modellen erforderlich für MQTT-Steuerung.',
+      dockerWarning: 'Docker-Bridge-Nutzer: Die Erkennung findet den Drucker möglicherweise nicht. Verwenden Sie stattdessen den manuellen IP-Pfad.',
+      addViaDiscovery: 'Per Erkennung hinzufügen',
+      addManually: 'Manuell per IP hinzufügen',
+    },
+    verifyConnection: {
+      title: 'Prüfen wir, ob Bambuddy mit dem Drucker spricht',
+      mqttLabel: 'MQTT-Steuerung (Port 8883)',
+      cameraLabel: 'Kamera-Stream (RTSPS Port 322, nur X1 / H2 / P2S)',
+      ftpLabel: 'Dateiübertragung (FTP Port 990)',
+      allGreen: 'Alle drei Kanäle haben den Drucker innerhalb von 30 Sekunden erreicht.',
+      issuesFound: '{{count}} Probleme gefunden — öffnen Sie die Diagnose für Details.',
+      runDiagnostic: 'Vollständige Diagnose ausführen',
+    },
+    tourCard: {
+      title: 'Eine kurze Tour durch die Druckerkachel',
+      status: 'Statuszeile — Druckerzustand, Restzeit, aktuelle Phase. Ihr Überblick auf einen Blick.',
+      ams: 'AMS-Zeile — Slot-Farben und -Typen kommen aus RFID, der Rest aus Ihrem Bestand; der Trocknen-Knopf sitzt ebenfalls hier.',
+      camera: 'Kamera-Kachel — derselbe Live-Stream, den BambuStudio nutzt, aber lokal. Kein Cloud-Umweg.',
+      controls: 'Steuerung — Pause, Fortsetzen, Abbrechen, Licht, Lüfter — dieselben Bedienelemente wie am Drucker-Display.',
+      customize: 'Rechtsklick auf die Kachel ordnet Felder neu an oder blendet aus, was Sie nicht brauchen.',
+    },
+    inventoryMode: {
+      title: 'Filament im Blick behalten',
+      body: 'Bambuddy kann Ihre Spulen verwalten. Wählen Sie jetzt einen Modus — späteres Wechseln verliert historische Daten.',
+      internalTitle: 'Intern (empfohlen)',
+      internalBody: 'Eingebauter Bestand, spiegelt das AMS, liest RFID, zieht das Gewicht beim Drucken automatisch ab. Für die meisten Nutzer am besten.',
+      spoolmanTitle: 'Spoolman',
+      spoolmanBody: 'Verweist auf eine bestehende Spoolman-Instanz, Bambuddy synchronisiert von dort. Ideal, wenn Sie Spoolman bereits betreiben.',
+      noneTitle: 'Aus',
+      noneBody: 'Filament-Verfolgung komplett überspringen. Sie können sie später einschalten, vergangene Drucke werden aber nicht nachgetragen.',
+      footgun: 'Ein späterer Moduswechsel überträgt bereits eingetragene Daten nicht.',
+    },
+    addSpool: {
+      title: 'Erste Spule anlegen',
+      intro: 'Wählen Sie die für Sie passende Methode:',
+      rfidTitle: 'RFID-Scan (Bambu-Spulen)',
+      rfidBody: 'Legen Sie die Spule ins AMS — Bambuddy liest das RFID-Tag automatisch. Kein manuelles Erfassen nötig.',
+      spoolbuddyTitle: 'SpoolBuddy',
+      spoolbuddyBody: 'Mit einer SpoolBuddy-Box können Sie ein beschreibbares RFID-Tag für Fremdspulen anlegen.',
+      manualTitle: 'Von Hand erfassen',
+      manualBody: 'Marke, Material, Farbe, Gewicht.',
+      catalog: 'Der eingebaute Farbkatalog deckt die großen Hersteller ab — Namen werden beim Tippen vervollständigt.',
+      addManually: 'Von Hand hinzufügen',
+      useRfid: 'RFID verwenden',
+    },
+    spoolmanSync: {
+      title: 'Bambuddy mitteilen, wo Spoolman läuft',
+      body: 'Bambuddy übernimmt Ihre Spulen-Bibliothek und hält sie synchron. RFID-Scans funktionieren weiterhin — sie legen neue Spulen direkt in Spoolman an.',
+    },
+    bambuCloud: {
+      title: 'Filament- und Druckprofile aus der Bambu-Cloud übernehmen',
+      body: 'Wenn Sie eigene Filament- oder Druckprofile in BambuStudio und der Bambu-Cloud pflegen, kann Bambuddy sie hereinholen, damit Web-UI, Warteschlange und virtueller Drucker dieselbe Bibliothek sehen.',
+      storage: 'Zugangsdaten werden verschlüsselt gespeichert und ausschließlich an die offizielle Bambu-API gesendet. Der Quellcode ist offen.',
+      signIn: 'Bei Bambu anmelden',
+      useDefaults: 'Überspringen — eingebaute Standards nutzen',
+    },
+    sidebar: {
+      title: 'Der Rest von Bambuddy auf einen Blick',
+      queue: 'Druckwarteschlange — Jobs per Drag-and-drop, automatische Zuweisung an freie Drucker, automatisches Trocknen für PETG und PA.',
+      archives: 'Archive — jeder abgeschlossene Druck mit Timelapse, Endfoto, Gcode und 3MF. Direkt aus jeder Zeile neu drucken.',
+      stats: 'Statistik — Stunden, Filament nach Marke, Material und Farbe, Energiekosten (Strompreis einmal hinterlegen), Erfolgsquote.',
+      maintenance: 'Wartung — Düsenverschleiß, Riemenspannung, Hotend-Tausch, Schmierintervalle. Vordefinierte Aufgaben plus eigene.',
+      files: 'Dateien — Ihre Bibliothek aus 3MF, Gcode und STL. Hochladen, taggen, suchen, an jeden Drucker senden. Externe Roots binden NAS-Freigaben ein.',
+      projects: 'Projekte — Dateien in ein logisches Projekt gruppieren und nachverfolgen, welche Platten gedruckt sind.',
+      helpIcon: 'Jede Seite hat oben rechts ein Fragezeichen-Symbol, das die passende Wiki-Seite direkt im Kontext öffnet.',
+    },
+    vp: {
+      title: 'Virtueller Drucker — der Slicer schickt direkt an Bambuddy',
+      body: 'BambuStudio oder OrcaSlicer können Drucke statt an die Bambu-Cloud an Bambuddy schicken. Wählen Sie den Modus, der zu Ihrem Aufbau passt:',
+      bridgeTitle: 'Brücke',
+      bridgeBody: 'Direkter Cloud-Ersatz. Der Slicer sendet an Bambuddy, Bambuddy reicht an den echten Drucker weiter.',
+      queueTitle: 'Warteschlange',
+      queueBody: 'Der Slicer sendet an einen virtuellen Sammler und Bambuddy stellt den Job in die Warteschlange.',
+      proxyTitle: 'Proxy-Modus',
+      proxyBody: 'Der Slicer spricht mit Bambuddy, das durchschleift mit vollständiger MQTT-, FTP- und RTSP-Umschreibung. Ideal für Multi-Slicer-Setups.',
+      archiveTitle: 'Archiv / Freigabe',
+      archiveBody: 'Der Slicer sendet, Bambuddy speichert, aber druckt nicht. Sinnvoll für Audit- und Freigabe-Abläufe.',
+      ipNote: 'Der VP reserviert eine freie IP auf Ihrer Bind-Schnittstelle, damit er für den Slicer wie ein echter Drucker aussieht.',
+      dockerWarning: 'Im Docker-Bridge-Modus müssen Ports explizit freigegeben sein — siehe die Docker-Wiki-Seite zur FTP-Passiv-Port-Aufteilung.',
+      setUp: 'Virtuellen Drucker einrichten',
+    },
+    slicerApi: {
+      title: 'Aus MakerWorld-URLs oder Ihrer Bibliothek slicen — ohne BambuStudio zu öffnen',
+      body: 'Benötigt den orca-slicer-api-Sidecar-Container (eigenes docker-compose, Link unten). Bambuddy spricht per HTTP mit ihm.',
+      status: 'Status: vorgelagert noch in Reifung — für Single-Filament- und Einzelplatten-Jobs heute zuverlässig, der Multi-Filament-3MF-Segfault wird vorgelagert behoben.',
+      configure: 'Sidecar konfigurieren',
+    },
+    externalRoots: {
+      title: 'NAS-Freigabe, externe SSD oder Projekt-Laufwerk einbinden',
+      body: 'Setzen Sie BAMBUDDY_EXTERNAL_ROOTS in docker-compose.yml und mounten Sie den Host-Pfad. Bambuddy zeigt den Ordner automatisch im Dateimanager.',
+      readOnlyWarning: 'Read-only einbinden, sofern Sie nicht ausdrücklich möchten, dass Nutzer auf die Freigabe zurückschreiben.',
+    },
+    makerworld: {
+      title: 'MakerWorld-URL einfügen — Bambuddy lädt das 3MF für Sie herunter',
+      body: 'In-App-Suche ist für diese Version entfallen, fügen Sie die URL von der MakerWorld-Website ein. Importe folgen Ihrer Ordnerstruktur.',
+      tryNow: 'Jetzt ausprobieren',
+    },
+    obico: {
+      title: 'Selbstgehostete ML-Druckfehler-Erkennung — kein Obico-Cloud-Konto',
+      body: 'Bambuddy spricht direkt mit Ihrem selbstgehosteten Obico-ML-Server. Pro Drucker aktivierbar, standardmäßig aus.',
+      smoothing: 'Glättung und Totzone werden auf der druckerspezifischen Obico-Seite eingestellt.',
+    },
+    integrations: {
+      title: 'Home Assistant und Webhooks',
+      body: 'Erstklassige Home-Assistant-Integration: Sensoren je Drucker (Zustand, Temperatur, Restzeit, AMS-Slots) sowie Dienste für Starten, Pausieren und Abbrechen. Webhooks feuern bei Druck-, Warteschlangen- und Archiv-Ereignissen.',
+      secret: 'Das Signatur-Geheimnis für Webhooks liegt unter Einstellungen → Integrationen.',
+    },
+    tailscale: {
+      title: 'Bambuddy von überall über Ihr Tailnet erreichen',
+      body: 'MagicDNS-HTTPS mit Let-us-Encrypt — Bambuddy fordert Zertifikate per tailscale cert an und stellt sie selbst bereit.',
+      openSettings: 'Tailscale-Einstellungen öffnen',
+    },
+    notifications: {
+      title: 'Bescheid bekommen, wenn Drucke fertig werden, scheitern oder Aufmerksamkeit brauchen',
+      body: 'Kanäle: In-App, Browser-Push, Discord, Telegram, Pushover, Gotify, ntfy, E-Mail und Webhook. Ereignisfilter leiten AMS-Luftfeuchte-Warnungen an Discord, Endfotos an Telegram und so weiter.',
+      configure: 'Jetzt einrichten',
+    },
+    users: {
+      title: 'Konten für den Rest Ihres Teams anlegen',
+      body: 'Jeder Nutzer hat eigene Berechtigungen, Druckhistorie und Benachrichtigungseinstellungen. Das Druckprotokoll zeigt, wer welchen Job gestartet hat.',
+      addUser: 'Nutzer hinzufügen',
+    },
+    groups: {
+      title: 'Nutzer nach Rolle gruppieren',
+      body: 'Bambuddy bringt Standardgruppen mit: Administratoren, Bediener, Betrachter. Eigene Gruppen für besondere Rollen — ein nur lesendes Kinderkonto, ein voll berechtigter Partner und so weiter.',
+    },
+    sso: {
+      title: 'Single Sign-on und MFA',
+      body: 'OIDC (Authentik, Authelia, Keycloak, Google, GitHub) und SAML 2.0 für Organisations-SSO. Pro-Nutzer-MFA per TOTP. Der Verschlüsselungsschlüssel wird beim ersten Start erzeugt; per Umgebungsvariable für Geheimnis-Manager überschreibbar.',
+      configureOidc: 'OIDC einrichten',
+      configureSaml: 'SAML einrichten',
+      enableMfa: 'MFA für mein Konto aktivieren',
+    },
+    outro: {
+      title: 'Alles bereit — hier finden Sie Hilfe, wenn etwas hakt',
+      system: 'Systemseite — Version, Logs, Debug-Bundle, Support-Export.',
+      diagnostic: 'Verbindungs-Diagnose — Drucker verbindet sich nicht, Kamera schwarz, FTP scheitert. Aus dem Menü der Druckerkachel.',
+      logScanner: 'Log-Health-Scanner — meldet wiederkehrende Laufzeitprobleme mit bekannten Lösungsvorschlägen.',
+      wiki: 'Wiki unter wiki.bambuddy.cool — vollständige Funktions-Dokumentation.',
+      discord: 'Discord — Community-Hilfe, schneller als GitHub für Anwendungsfragen.',
+      github: 'GitHub Issues — für echte Bugs und Funktionswünsche.',
+      rehome: 'Tour erneut sehen? Sie liegt unten in der Seitenleiste.',
+    },
+    helpIcon: {
+      openWiki: 'Wiki-Seite zu diesem Bereich öffnen',
+    },
+  },
 };

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

@@ -6248,4 +6248,198 @@ export default {
     noReadAccess: 'You do not have permission to view inventory forecasts.',
     noWriteAccess: 'You do not have permission to modify forecast settings.',
   },
+
+  // Onboarding tour. Keys mirror the step layout in docs/onboarding-tour-plan.md.
+  // Each step exposes title + body + step-specific buttons; shared button labels
+  // live under onboarding.button.* so they reuse across steps.
+  onboarding: {
+    button: {
+      next: 'Continue',
+      back: 'Back',
+      skip: 'Skip',
+      skipTour: 'Skip the tour',
+      done: 'Done',
+      interested: 'Tell me more',
+      notInterested: 'Show me later',
+      remindLater: 'Remind me later',
+    },
+    welcome: {
+      title: 'Welcome to Bambuddy',
+      body: 'Bambuddy replaces the Bambu Lab cloud with a local-first dashboard. Your data, prints, spools, and timelapses stay on your hardware. Want a five-minute tour?',
+      startTour: 'Start the tour',
+      experienced: 'I already know my way around',
+    },
+    about: {
+      title: 'What Bambuddy is — and what it is not',
+      doesTitle: 'What Bambuddy does',
+      doesBody: 'Replaces the Bambu cloud locally, tracks AMS and filament inventory with RFID, runs a print queue, archives every finished print, serves a virtual printer to your slicer, supports multiple users, and ships first-class Home Assistant and Tailscale integration.',
+      isntTitle: 'What Bambuddy is not',
+      isntBody: 'Not a slicer (Bambuddy hands off to BambuStudio or OrcaSlicer), not a cloud service, not a firmware tool, not a Klipper UI.',
+      privacy: 'No telemetry. No accounts. bambuddy.cool only serves the docs.',
+    },
+    auth: {
+      title: 'Lock the front door first',
+      body: 'If anyone else on your network, your tailnet, or your reverse proxy can reach this URL, turn on authentication now. Passwords, OIDC, SAML, and MFA are all built in.',
+      severity: 'Bambuddy can control your printers, manage your files, and read your camera feeds — treat the URL like an admin panel.',
+      enableNow: 'Enable authentication now',
+      later: 'Later — this URL is private',
+    },
+    addPrinter: {
+      title: 'Add your first printer',
+      body: 'You need three things: the printer model, its IP address on your network, and its access code.',
+      modelLabel: 'Model',
+      modelHint: 'Auto-detected via discovery, or pick it manually if Bambuddy can not see the printer.',
+      ipLabel: 'IP address',
+      ipHint: 'Shown on the printer LCD under Settings → Network. A DHCP reservation in your router keeps it stable.',
+      codeLabel: 'Access code',
+      codeHint: 'Shown on the printer LCD; the path depends on the model — see the popover below.',
+      lanModeWarning: 'Enable LAN-only mode on the printer (X1, H2, and P2S family). Without it the MQTT and FTP ports stay blocked.',
+      devModeWarning: 'Enable Developer Mode on the printer LCD — required for MQTT control on most models.',
+      dockerWarning: 'Docker bridge users: discovery may not find the printer. Use the manual-by-IP path instead.',
+      addViaDiscovery: 'Add via discovery',
+      addManually: 'Add manually by IP',
+    },
+    verifyConnection: {
+      title: 'Let us make sure Bambuddy can talk to it',
+      mqttLabel: 'MQTT control (port 8883)',
+      cameraLabel: 'Camera stream (RTSPS port 322, X1 / H2 / P2S only)',
+      ftpLabel: 'File transfer (FTP port 990)',
+      allGreen: 'All three channels reached the printer within 30 seconds.',
+      issuesFound: 'Found {{count}} issues — open the diagnostic for details.',
+      runDiagnostic: 'Run the full diagnostic',
+    },
+    tourCard: {
+      title: 'A quick tour of the printer card',
+      status: 'Status row — printer state, ETA, current stage. Your at-a-glance status.',
+      ams: 'AMS row — slot colors and types come from RFID, the rest from your inventory; the drying button lives here too.',
+      camera: 'Camera tile — the same live stream BambuStudio uses, but local. No cloud round-trip.',
+      controls: 'Controls — pause, resume, cancel, lights, fans — the same controls you have on the printer LCD.',
+      customize: 'Right-click the card to rearrange tiles or hide what you do not need.',
+    },
+    inventoryMode: {
+      title: 'Track your filament',
+      body: 'Bambuddy can keep tabs on your spools. Pick a mode now — switching later loses historical data.',
+      internalTitle: 'Internal (recommended)',
+      internalBody: 'Built-in inventory, mirrors the AMS, reads RFID, auto-decrements weight as you print. Best for most users.',
+      spoolmanTitle: 'Spoolman',
+      spoolmanBody: 'Point at an existing Spoolman instance and Bambuddy syncs from it. Best if you already run Spoolman.',
+      noneTitle: 'Off',
+      noneBody: 'Skip filament tracking entirely. You can switch it on later, but past prints will not backfill.',
+      footgun: 'Switching modes later does not migrate the data you have already entered.',
+    },
+    addSpool: {
+      title: 'Add your first spool',
+      intro: 'Pick whichever method suits you:',
+      rfidTitle: 'RFID scan (Bambu spools)',
+      rfidBody: 'Load the spool in the AMS — Bambuddy reads the RFID tag automatically. No manual entry needed.',
+      spoolbuddyTitle: 'SpoolBuddy',
+      spoolbuddyBody: 'If you have a SpoolBuddy box, scan a writable RFID tag for non-Bambu spools.',
+      manualTitle: 'Enter by hand',
+      manualBody: 'Brand, material, color, weight.',
+      catalog: 'The built-in color catalog covers the major brands — names autocomplete as you type.',
+      addManually: 'Add by hand',
+      useRfid: 'Use RFID',
+    },
+    spoolmanSync: {
+      title: 'Tell Bambuddy where Spoolman lives',
+      body: 'Bambuddy pulls your existing spool library and keeps it in sync. RFID scans still work — they create new spools inside Spoolman.',
+    },
+    bambuCloud: {
+      title: 'Sync your filament and print profiles from Bambu Lab',
+      body: 'If you keep custom filament or print profiles in BambuStudio and the Bambu cloud, Bambuddy can pull them in so the web UI, the queue, and the virtual printer all see the same library.',
+      storage: 'Credentials are stored encrypted at rest and only sent to the official Bambu API. The source is open.',
+      signIn: 'Sign in to Bambu',
+      useDefaults: 'Skip — use the built-in defaults',
+    },
+    sidebar: {
+      title: 'The rest of Bambuddy at a glance',
+      queue: 'Print Queue — drag-and-drop jobs, auto-dispatch to idle printers, auto-drying for PETG and PA.',
+      archives: 'Archives — every finished print, with timelapse, finish photo, gcode, and 3MF. Re-print straight from any row.',
+      stats: 'Statistics — hours, filament by brand and material and color, energy cost (set the electricity price once), success rate.',
+      maintenance: 'Maintenance — nozzle wear, belt tension, hotend swap, grease intervals. Built-in defaults plus custom tasks.',
+      files: 'Files — your 3MF, gcode, and STL library. Upload, tag, search, send to any printer. External roots mount NAS shares.',
+      projects: 'Projects — group files into a logical project and track which plates are printed.',
+      helpIcon: 'Every page has a question-mark icon in the top-right that opens the matching wiki page in-context.',
+    },
+    vp: {
+      title: 'Virtual Printer — let your slicer send straight to Bambuddy',
+      body: 'BambuStudio or OrcaSlicer can send prints to Bambuddy instead of the Bambu cloud. Pick the mode that fits your setup:',
+      bridgeTitle: 'Bridge mode',
+      bridgeBody: 'Drop-in cloud replacement. The slicer sends to Bambuddy, Bambuddy forwards to the real printer.',
+      queueTitle: 'Queue mode',
+      queueBody: 'The slicer sends to a virtual collector and Bambuddy queues the job for dispatch.',
+      proxyTitle: 'Proxy mode',
+      proxyBody: 'The slicer talks to Bambuddy, which passes through with full MQTT, FTP, and RTSP rewriting. Best for multi-slicer setups.',
+      archiveTitle: 'Archive / Review',
+      archiveBody: 'The slicer sends, Bambuddy stores but does not print. Useful for audit and approval workflows.',
+      ipNote: 'The VP claims a free IP on your bind interface so it looks like a real printer to the slicer.',
+      dockerWarning: 'Docker bridge mode needs explicit port exposure — see the Docker wiki page for FTP passive port slicing.',
+      setUp: 'Set up a Virtual Printer',
+    },
+    slicerApi: {
+      title: 'Slice from MakerWorld URLs or your library — without opening BambuStudio',
+      body: 'Requires the orca-slicer-api sidecar container (separate docker-compose, link below). Bambuddy talks to it over HTTP.',
+      status: 'Status: still maturing upstream — solid for single-filament and single-plate jobs today, the multi-filament 3MF segfault is being patched upstream.',
+      configure: 'Configure the sidecar',
+    },
+    externalRoots: {
+      title: 'Mount a NAS share, an external SSD, or a project drive',
+      body: 'Set BAMBUDDY_EXTERNAL_ROOTS in docker-compose.yml and bind-mount the host path. Bambuddy auto-shows the folder in the File Manager.',
+      readOnlyWarning: 'Mount read-only unless you specifically want users uploading back to the share.',
+    },
+    makerworld: {
+      title: 'Paste any MakerWorld URL — Bambuddy downloads the 3MF for you',
+      body: 'In-app search was cut for this release, so paste the URL from the MakerWorld site. Imports respect your external-folder layout.',
+      tryNow: 'Try it now',
+    },
+    obico: {
+      title: 'Self-hosted ML print-failure detection — no Obico cloud account',
+      body: 'Bambuddy talks directly to your self-hosted Obico ML server. Opt-in per printer, off by default.',
+      smoothing: 'Smoothing and dead-zone tuning lives on the printer-specific Obico panel.',
+    },
+    integrations: {
+      title: 'Home Assistant and webhooks',
+      body: 'First-class Home Assistant integration: sensors for every printer (state, temperature, ETA, AMS slots) and services to start, pause, or cancel. Webhooks fire on print events, queue events, and archive events.',
+      secret: 'The webhook signing secret lives in Settings → Integrations.',
+    },
+    tailscale: {
+      title: 'Access Bambuddy from anywhere via your tailnet',
+      body: 'MagicDNS HTTPS with Let us Encrypt — Bambuddy requests certificates via tailscale cert and serves them itself.',
+      openSettings: 'Open the Tailscale settings',
+    },
+    notifications: {
+      title: 'Get told when prints finish, fail, or need attention',
+      body: 'Channels: in-app, browser push, Discord, Telegram, Pushover, Gotify, ntfy, email, and webhook. Per-event filters route AMS humidity warnings to Discord, finish photos to Telegram, and so on.',
+      configure: 'Configure now',
+    },
+    users: {
+      title: 'Add accounts for the rest of your team',
+      body: 'Every user gets their own permissions, print history, and notification settings. The print log shows who started which job.',
+      addUser: 'Add a user',
+    },
+    groups: {
+      title: 'Group users by role',
+      body: 'Bambuddy ships with default groups: Administrators, Operators, Viewers. Build your own groups for custom roles — a read-only kid account, a full-access partner, and so on.',
+    },
+    sso: {
+      title: 'Single sign-on and MFA',
+      body: 'OIDC (Authentik, Authelia, Keycloak, Google, GitHub) and SAML 2.0 for organization SSO. Per-user MFA via TOTP. The encryption key generates itself on first start; override via env var for secret-manager workflows.',
+      configureOidc: 'Configure OIDC',
+      configureSaml: 'Configure SAML',
+      enableMfa: 'Enable MFA on my account',
+    },
+    outro: {
+      title: 'You are all set — here is where to go when something is off',
+      system: 'System page — version, logs, debug bundle, support export.',
+      diagnostic: 'Connection Diagnostic — printer will not connect, camera black, FTP fails. Open from the printer card menu.',
+      logScanner: 'Log Health Scanner — flags recurring runtime issues with known-fix suggestions.',
+      wiki: 'Wiki at wiki.bambuddy.cool — full feature docs.',
+      discord: 'Discord — community help, faster than GitHub for usage questions.',
+      github: 'GitHub Issues — for actual bugs and feature requests.',
+      rehome: 'Need to see this tour again? It lives at the bottom of the sidebar.',
+    },
+    helpIcon: {
+      openWiki: 'Open the wiki page for this section',
+    },
+  },
 };

+ 192 - 0
frontend/src/i18n/locales/es.ts

@@ -6244,4 +6244,196 @@ export default {
     noReadAccess: 'No tiene permiso para ver las previsiones de inventario.',
     noWriteAccess: 'No tiene permiso para modificar los ajustes de previsión.',
   },
+
+  // Tour de incorporación
+  onboarding: {
+    button: {
+      next: 'Continuar',
+      back: 'Atrás',
+      skip: 'Omitir',
+      skipTour: 'Omitir el tour',
+      done: 'Listo',
+      interested: 'Cuéntame más',
+      notInterested: 'Mostrar más tarde',
+      remindLater: 'Recordar más tarde',
+    },
+    welcome: {
+      title: 'Bienvenido a Bambuddy',
+      body: 'Bambuddy reemplaza la nube de Bambu Lab por un panel local. Sus datos, impresiones, bobinas y timelapses se quedan en su hardware. ¿Quiere un recorrido de cinco minutos?',
+      startTour: 'Iniciar el tour',
+      experienced: 'Ya me manejo solo',
+    },
+    about: {
+      title: 'Qué es Bambuddy y qué no es',
+      doesTitle: 'Qué hace Bambuddy',
+      doesBody: 'Reemplaza la nube de Bambu en local, sigue el AMS y el inventario de filamento mediante RFID, gestiona una cola de impresión, archiva cada impresión terminada, expone una impresora virtual al laminador, soporta varios usuarios y trae integración nativa con Home Assistant y Tailscale.',
+      isntTitle: 'Qué no es Bambuddy',
+      isntBody: 'No es un laminador (Bambuddy delega en BambuStudio u OrcaSlicer), no es un servicio en la nube, no es una herramienta de firmware ni una interfaz para Klipper.',
+      privacy: 'Sin telemetría. Sin cuentas. bambuddy.cool solo sirve la documentación.',
+    },
+    auth: {
+      title: 'Primero cierre la puerta de entrada',
+      body: 'Si alguien más en su red, en su tailnet o en su proxy inverso puede llegar a esta URL, active la autenticación ya. Contraseñas, OIDC, SAML y MFA vienen incluidos.',
+      severity: 'Bambuddy puede controlar sus impresoras, gestionar sus archivos y leer las cámaras — trate esta URL como un panel de administración.',
+      enableNow: 'Activar la autenticación ahora',
+      later: 'Más tarde — esta URL es privada',
+    },
+    addPrinter: {
+      title: 'Añada su primera impresora',
+      body: 'Necesita tres cosas: el modelo de la impresora, su dirección IP en la red y su código de acceso.',
+      modelLabel: 'Modelo',
+      modelHint: 'Detectado automáticamente por descubrimiento, o elíjalo a mano si Bambuddy no logra verla.',
+      ipLabel: 'Dirección IP',
+      ipHint: 'Aparece en la pantalla de la impresora bajo Ajustes → Red. Una reserva DHCP en el router la mantiene estable.',
+      codeLabel: 'Código de acceso',
+      codeHint: 'Aparece en la pantalla de la impresora; la ruta depende del modelo — vea el aviso emergente.',
+      lanModeWarning: 'Active el modo LAN-only en la impresora (familias X1, H2 y P2S). Sin él los puertos MQTT y FTP quedan bloqueados.',
+      devModeWarning: 'Active el modo de desarrollador en la pantalla — necesario para el control MQTT en la mayoría de modelos.',
+      dockerWarning: 'Usuarios de Docker bridge: puede que el descubrimiento no encuentre la impresora. Use la ruta manual por IP.',
+      addViaDiscovery: 'Añadir por descubrimiento',
+      addManually: 'Añadir manualmente por IP',
+    },
+    verifyConnection: {
+      title: 'Comprobemos que Bambuddy se comunica con ella',
+      mqttLabel: 'Control MQTT (puerto 8883)',
+      cameraLabel: 'Flujo de cámara (RTSPS puerto 322, solo X1 / H2 / P2S)',
+      ftpLabel: 'Transferencia de archivos (FTP puerto 990)',
+      allGreen: 'Los tres canales alcanzaron la impresora en menos de 30 segundos.',
+      issuesFound: 'Se han detectado {{count}} problemas — abra el diagnóstico para ver los detalles.',
+      runDiagnostic: 'Ejecutar diagnóstico completo',
+    },
+    tourCard: {
+      title: 'Un repaso rápido a la tarjeta de impresora',
+      status: 'Fila de estado — estado de la impresora, tiempo restante, fase actual. Su resumen de un vistazo.',
+      ams: 'Fila AMS — los colores y tipos de cada hueco vienen del RFID, el resto del inventario; el botón de secado también vive aquí.',
+      camera: 'Mosaico de cámara — el mismo flujo en vivo que usa BambuStudio, pero local. Sin pasar por la nube.',
+      controls: 'Controles — pausar, reanudar, cancelar, luces, ventiladores — los mismos mandos que en la pantalla.',
+      customize: 'Clic derecho en la tarjeta para reordenar los mosaicos u ocultar lo que no necesite.',
+    },
+    inventoryMode: {
+      title: 'Controle su filamento',
+      body: 'Bambuddy puede llevar la cuenta de sus bobinas. Elija un modo ahora — cambiarlo más tarde pierde el histórico.',
+      internalTitle: 'Interno (recomendado)',
+      internalBody: 'Inventario integrado, refleja el AMS, lee RFID y descuenta el peso automáticamente al imprimir. Lo mejor para la mayoría.',
+      spoolmanTitle: 'Spoolman',
+      spoolmanBody: 'Apunta a una instancia existente de Spoolman y Bambuddy sincroniza desde ella. Ideal si ya usa Spoolman.',
+      noneTitle: 'Apagado',
+      noneBody: 'Omitir por completo el seguimiento de filamento. Puede activarlo luego, pero las impresiones pasadas no se rellenarán.',
+      footgun: 'Cambiar de modo más tarde no migra los datos que ya haya introducido.',
+    },
+    addSpool: {
+      title: 'Añada su primera bobina',
+      intro: 'Elija el método que prefiera:',
+      rfidTitle: 'Lectura RFID (bobinas Bambu)',
+      rfidBody: 'Cargue la bobina en el AMS — Bambuddy lee la etiqueta RFID por sí solo. Sin captura manual.',
+      spoolbuddyTitle: 'SpoolBuddy',
+      spoolbuddyBody: 'Con una caja SpoolBuddy puede grabar una etiqueta RFID para bobinas que no sean de Bambu.',
+      manualTitle: 'Introducir a mano',
+      manualBody: 'Marca, material, color, peso.',
+      catalog: 'El catálogo de colores incluido cubre las grandes marcas — los nombres se autocompletan al teclear.',
+      addManually: 'Añadir a mano',
+      useRfid: 'Usar RFID',
+    },
+    spoolmanSync: {
+      title: 'Indique a Bambuddy dónde está Spoolman',
+      body: 'Bambuddy importa su biblioteca de bobinas y la mantiene sincronizada. Las lecturas RFID siguen funcionando — crean bobinas nuevas dentro de Spoolman.',
+    },
+    bambuCloud: {
+      title: 'Sincronice sus perfiles de filamento e impresión desde Bambu Lab',
+      body: 'Si guarda perfiles personalizados en BambuStudio y la nube de Bambu, Bambuddy puede traerlos para que la web, la cola y la impresora virtual vean la misma biblioteca.',
+      storage: 'Las credenciales se guardan cifradas en reposo y solo se envían a la API oficial de Bambu. El código fuente es abierto.',
+      signIn: 'Iniciar sesión en Bambu',
+      useDefaults: 'Omitir — usar los valores por defecto',
+    },
+    sidebar: {
+      title: 'El resto de Bambuddy de un vistazo',
+      queue: 'Cola de impresión — trabajos por arrastrar y soltar, asignación automática a impresoras libres, secado automático para PETG y PA.',
+      archives: 'Archivos — cada impresión terminada, con timelapse, foto final, gcode y 3MF. Reimprima desde cualquier fila.',
+      stats: 'Estadísticas — horas, filamento por marca, material y color, coste energético (fije el precio de la luz una vez), tasa de éxito.',
+      maintenance: 'Mantenimiento — desgaste de boquilla, tensión de correas, cambio de hotend, intervalos de engrase. Tareas predefinidas más las suyas.',
+      files: 'Archivos — su biblioteca de 3MF, gcode y STL. Subir, etiquetar, buscar, enviar a cualquier impresora. Las raíces externas montan recursos NAS.',
+      projects: 'Proyectos — agrupe archivos en un proyecto lógico y siga qué placas se han impreso.',
+      helpIcon: 'Cada página tiene un icono de interrogación arriba a la derecha que abre la página de wiki correspondiente en contexto.',
+    },
+    vp: {
+      title: 'Impresora virtual — que el laminador envíe directamente a Bambuddy',
+      body: 'BambuStudio u OrcaSlicer pueden enviar las impresiones a Bambuddy en lugar de a la nube de Bambu. Elija el modo que encaja con su montaje:',
+      bridgeTitle: 'Puente',
+      bridgeBody: 'Sustituto directo de la nube. El laminador envía a Bambuddy y Bambuddy reenvía a la impresora real.',
+      queueTitle: 'Cola',
+      queueBody: 'El laminador envía a un recolector virtual y Bambuddy encola el trabajo para asignación.',
+      proxyTitle: 'Modo proxy',
+      proxyBody: 'El laminador habla con Bambuddy, que pasa todo con reescritura completa de MQTT, FTP y RTSP. Ideal para varios laminadores.',
+      archiveTitle: 'Archivo / Revisión',
+      archiveBody: 'El laminador envía, Bambuddy guarda pero no imprime. Útil para flujos de auditoría y aprobación.',
+      ipNote: 'La VP reserva una IP libre en su interfaz de bind para parecer una impresora real al laminador.',
+      dockerWarning: 'En modo Docker bridge hace falta exponer puertos explícitamente — vea la página de la wiki sobre el reparto de puertos pasivos FTP.',
+      setUp: 'Configurar una impresora virtual',
+    },
+    slicerApi: {
+      title: 'Laminar desde URLs de MakerWorld o desde su biblioteca — sin abrir BambuStudio',
+      body: 'Requiere el contenedor sidecar orca-slicer-api (docker-compose aparte, enlace abajo). Bambuddy habla con él por HTTP.',
+      status: 'Estado: aún madurando aguas arriba — sólido hoy para trabajos de un filamento y una placa; el segfault con 3MF multifilamento se está parcheando en el upstream.',
+      configure: 'Configurar el sidecar',
+    },
+    externalRoots: {
+      title: 'Montar un recurso NAS, un SSD externo o una unidad de proyecto',
+      body: 'Defina BAMBUDDY_EXTERNAL_ROOTS en docker-compose.yml y monte la ruta del host. Bambuddy mostrará la carpeta automáticamente en el gestor de archivos.',
+      readOnlyWarning: 'Montar en solo lectura salvo que quiera expresamente que los usuarios suban al recurso.',
+    },
+    makerworld: {
+      title: 'Pegue cualquier URL de MakerWorld — Bambuddy se descarga el 3MF',
+      body: 'La búsqueda en la app no entró en esta versión, así que pegue la URL desde la web de MakerWorld. Las importaciones respetan su disposición de carpetas.',
+      tryNow: 'Probar ahora',
+    },
+    obico: {
+      title: 'Detección ML de fallos de impresión autoalojada — sin cuenta de Obico en la nube',
+      body: 'Bambuddy habla directamente con su servidor Obico ML autoalojado. Se activa por impresora y está desactivada por defecto.',
+      smoothing: 'El suavizado y la zona muerta se ajustan en el panel Obico de cada impresora.',
+    },
+    integrations: {
+      title: 'Home Assistant y webhooks',
+      body: 'Integración de primera con Home Assistant: sensores por impresora (estado, temperatura, tiempo restante, huecos AMS) y servicios para iniciar, pausar o cancelar. Los webhooks se disparan con eventos de impresión, de cola y de archivo.',
+      secret: 'El secreto de firma de los webhooks está en Ajustes → Integraciones.',
+    },
+    tailscale: {
+      title: 'Acceda a Bambuddy desde cualquier sitio por su tailnet',
+      body: 'MagicDNS con HTTPS vía Let us Encrypt — Bambuddy pide certificados con tailscale cert y los sirve por sí mismo.',
+      openSettings: 'Abrir los ajustes de Tailscale',
+    },
+    notifications: {
+      title: 'Que le avisen cuando una impresión acabe, falle o requiera atención',
+      body: 'Canales: en la app, push del navegador, Discord, Telegram, Pushover, Gotify, ntfy, correo y webhook. Los filtros por evento envían los avisos de humedad del AMS a Discord, las fotos finales a Telegram, etc.',
+      configure: 'Configurar ahora',
+    },
+    users: {
+      title: 'Añada cuentas para el resto del equipo',
+      body: 'Cada usuario tiene sus propios permisos, historial de impresiones y ajustes de notificaciones. El registro muestra quién arrancó cada trabajo.',
+      addUser: 'Añadir usuario',
+    },
+    groups: {
+      title: 'Agrupe a los usuarios por rol',
+      body: 'Bambuddy incluye grupos por defecto: Administradores, Operadores y Visores. Cree los suyos para roles a medida — una cuenta infantil solo lectura, una cuenta de pareja con acceso total, etc.',
+    },
+    sso: {
+      title: 'Inicio de sesión único y MFA',
+      body: 'OIDC (Authentik, Authelia, Keycloak, Google, GitHub) y SAML 2.0 para SSO corporativo. MFA por usuario mediante TOTP. La clave de cifrado se genera al primer arranque; sobreescríbala por variable de entorno si usa un gestor de secretos.',
+      configureOidc: 'Configurar OIDC',
+      configureSaml: 'Configurar SAML',
+      enableMfa: 'Activar MFA en mi cuenta',
+    },
+    outro: {
+      title: 'Todo listo — aquí encontrará ayuda si algo falla',
+      system: 'Página de Sistema — versión, registros, paquete de depuración, exportación de soporte.',
+      diagnostic: 'Diagnóstico de conexión — la impresora no conecta, cámara en negro, FTP falla. Se abre desde el menú de la tarjeta.',
+      logScanner: 'Escáner de salud de registros — marca problemas recurrentes con sugerencias de solución conocidas.',
+      wiki: 'Wiki en wiki.bambuddy.cool — documentación completa de funciones.',
+      discord: 'Discord — ayuda de la comunidad, más rápida que GitHub para dudas de uso.',
+      github: 'GitHub Issues — para bugs reales y peticiones de funciones.',
+      rehome: '¿Quiere ver el tour otra vez? Vive abajo en la barra lateral.',
+    },
+    helpIcon: {
+      openWiki: 'Abrir la página wiki de esta sección',
+    },
+  },
 };

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

@@ -6223,4 +6223,196 @@ export default {
     noReadAccess: 'Vous n\'avez pas la permission de consulter les prévisions de stock.',
     noWriteAccess: 'Vous n\'avez pas la permission de modifier les paramètres de prévision.',
   },
+
+  // Visite d\'intégration
+  onboarding: {
+    button: {
+      next: 'Continuer',
+      back: 'Retour',
+      skip: 'Passer',
+      skipTour: 'Passer la visite',
+      done: 'Terminé',
+      interested: 'En savoir plus',
+      notInterested: 'Afficher plus tard',
+      remindLater: 'Me le rappeler plus tard',
+    },
+    welcome: {
+      title: 'Bienvenue dans Bambuddy',
+      body: 'Bambuddy remplace le cloud Bambu Lab par un tableau de bord local. Vos données, impressions, bobines et timelapses restent sur votre matériel. Voulez-vous une visite de cinq minutes ?',
+      startTour: 'Démarrer la visite',
+      experienced: 'Je m\'y retrouve déjà',
+    },
+    about: {
+      title: 'Ce qu\'est Bambuddy — et ce qu\'il n\'est pas',
+      doesTitle: 'Ce que fait Bambuddy',
+      doesBody: 'Remplace le cloud Bambu localement, suit l\'AMS et l\'inventaire des filaments via RFID, gère une file d\'attente d\'impression, archive chaque impression terminée, fournit à votre trancheur une imprimante virtuelle, prend en charge plusieurs utilisateurs et propose une intégration native avec Home Assistant et Tailscale.',
+      isntTitle: 'Ce que n\'est pas Bambuddy',
+      isntBody: 'Pas un trancheur (Bambuddy délègue à BambuStudio ou OrcaSlicer), pas un service cloud, pas un outil de firmware, pas une interface Klipper.',
+      privacy: 'Aucune télémétrie. Aucun compte. bambuddy.cool ne sert que la documentation.',
+    },
+    auth: {
+      title: 'Verrouillez d\'abord la porte d\'entrée',
+      body: 'Si quelqu\'un d\'autre sur votre réseau, votre tailnet ou votre reverse proxy peut atteindre cette URL, activez l\'authentification dès maintenant. Mots de passe, OIDC, SAML et MFA sont intégrés.',
+      severity: 'Bambuddy peut piloter vos imprimantes, gérer vos fichiers et lire vos flux caméra — traitez cette URL comme un panneau d\'administration.',
+      enableNow: 'Activer l\'authentification maintenant',
+      later: 'Plus tard — cette URL est privée',
+    },
+    addPrinter: {
+      title: 'Ajoutez votre première imprimante',
+      body: 'Il vous faut trois choses : le modèle de l\'imprimante, son adresse IP sur votre réseau et son code d\'accès.',
+      modelLabel: 'Modèle',
+      modelHint: 'Détecté automatiquement par la découverte, ou choisissez-le manuellement si Bambuddy ne voit pas l\'imprimante.',
+      ipLabel: 'Adresse IP',
+      ipHint: 'Affichée sur l\'écran de l\'imprimante sous Paramètres → Réseau. Une réservation DHCP dans votre routeur la maintient stable.',
+      codeLabel: 'Code d\'accès',
+      codeHint: 'Affiché sur l\'écran de l\'imprimante ; le chemin dépend du modèle — voir la fenêtre contextuelle ci-dessous.',
+      lanModeWarning: 'Activez le mode LAN-only sur l\'imprimante (familles X1, H2 et P2S). Sans lui, les ports MQTT et FTP restent bloqués.',
+      devModeWarning: 'Activez le Mode Développeur sur l\'écran de l\'imprimante — requis pour le contrôle MQTT sur la plupart des modèles.',
+      dockerWarning: 'Utilisateurs de Docker bridge : la découverte peut ne pas trouver l\'imprimante. Utilisez plutôt l\'ajout manuel par IP.',
+      addViaDiscovery: 'Ajouter via la découverte',
+      addManually: 'Ajouter manuellement par IP',
+    },
+    verifyConnection: {
+      title: 'Vérifions que Bambuddy peut bien lui parler',
+      mqttLabel: 'Contrôle MQTT (port 8883)',
+      cameraLabel: 'Flux caméra (RTSPS port 322, X1 / H2 / P2S uniquement)',
+      ftpLabel: 'Transfert de fichiers (FTP port 990)',
+      allGreen: 'Les trois canaux ont atteint l\'imprimante en moins de 30 secondes.',
+      issuesFound: '{{count}} problèmes détectés — ouvrez le diagnostic pour les détails.',
+      runDiagnostic: 'Lancer le diagnostic complet',
+    },
+    tourCard: {
+      title: 'Un tour rapide de la carte imprimante',
+      status: 'Ligne d\'état — état de l\'imprimante, temps restant, étape en cours. Votre aperçu d\'un coup d\'œil.',
+      ams: 'Ligne AMS — les couleurs et types de chaque emplacement viennent du RFID, le reste de votre inventaire ; le bouton de séchage se trouve ici aussi.',
+      camera: 'Tuile caméra — le même flux en direct que celui utilisé par BambuStudio, mais en local. Pas d\'aller-retour par le cloud.',
+      controls: 'Commandes — pause, reprise, annulation, lumières, ventilateurs — les mêmes commandes que sur l\'écran de l\'imprimante.',
+      customize: 'Clic droit sur la carte pour réorganiser les tuiles ou masquer ce dont vous n\'avez pas besoin.',
+    },
+    inventoryMode: {
+      title: 'Suivez votre filament',
+      body: 'Bambuddy peut surveiller vos bobines. Choisissez un mode dès maintenant — changer plus tard fait perdre les données historiques.',
+      internalTitle: 'Interne (recommandé)',
+      internalBody: 'Inventaire intégré, reflète l\'AMS, lit le RFID, décrémente automatiquement le poids pendant l\'impression. Idéal pour la plupart des utilisateurs.',
+      spoolmanTitle: 'Spoolman',
+      spoolmanBody: 'Pointez vers une instance Spoolman existante et Bambuddy se synchronise depuis celle-ci. Idéal si vous utilisez déjà Spoolman.',
+      noneTitle: 'Désactivé',
+      noneBody: 'Ignorer complètement le suivi du filament. Vous pourrez l\'activer plus tard, mais les impressions passées ne seront pas reprises.',
+      footgun: 'Changer de mode plus tard ne migre pas les données déjà saisies.',
+    },
+    addSpool: {
+      title: 'Ajoutez votre première bobine',
+      intro: 'Choisissez la méthode qui vous convient :',
+      rfidTitle: 'Lecture RFID (bobines Bambu)',
+      rfidBody: 'Chargez la bobine dans l\'AMS — Bambuddy lit l\'étiquette RFID automatiquement. Aucune saisie manuelle nécessaire.',
+      spoolbuddyTitle: 'SpoolBuddy',
+      spoolbuddyBody: 'Si vous avez un boîtier SpoolBuddy, scannez une étiquette RFID inscriptible pour les bobines hors Bambu.',
+      manualTitle: 'Saisir à la main',
+      manualBody: 'Marque, matériau, couleur, poids.',
+      catalog: 'Le catalogue de couleurs intégré couvre les grandes marques — les noms se complètent automatiquement à la saisie.',
+      addManually: 'Ajouter à la main',
+      useRfid: 'Utiliser le RFID',
+    },
+    spoolmanSync: {
+      title: 'Indiquez à Bambuddy où se trouve Spoolman',
+      body: 'Bambuddy récupère votre bibliothèque de bobines existante et la maintient synchronisée. Les lectures RFID continuent de fonctionner — elles créent de nouvelles bobines directement dans Spoolman.',
+    },
+    bambuCloud: {
+      title: 'Synchronisez vos profils de filament et d\'impression depuis Bambu Lab',
+      body: 'Si vous conservez des profils personnalisés de filament ou d\'impression dans BambuStudio et le cloud Bambu, Bambuddy peut les importer afin que l\'interface web, la file d\'attente et l\'imprimante virtuelle voient la même bibliothèque.',
+      storage: 'Les identifiants sont stockés chiffrés au repos et envoyés uniquement à l\'API officielle Bambu. Le code source est ouvert.',
+      signIn: 'Se connecter à Bambu',
+      useDefaults: 'Passer — utiliser les valeurs par défaut intégrées',
+    },
+    sidebar: {
+      title: 'Le reste de Bambuddy en un coup d\'œil',
+      queue: 'File d\'attente d\'impression — glisser-déposer des tâches, attribution automatique aux imprimantes libres, séchage automatique pour PETG et PA.',
+      archives: 'Archives — chaque impression terminée, avec timelapse, photo finale, gcode et 3MF. Réimprimez directement depuis n\'importe quelle ligne.',
+      stats: 'Statistiques — heures, filament par marque, matériau et couleur, coût énergétique (fixez le prix de l\'électricité une seule fois), taux de réussite.',
+      maintenance: 'Maintenance — usure de la buse, tension des courroies, remplacement du hotend, intervalles de graissage. Tâches prédéfinies plus les vôtres.',
+      files: 'Fichiers — votre bibliothèque de 3MF, gcode et STL. Téléverser, étiqueter, rechercher, envoyer à n\'importe quelle imprimante. Les racines externes montent des partages NAS.',
+      projects: 'Projets — regroupez des fichiers dans un projet logique et suivez quelles plaques ont été imprimées.',
+      helpIcon: 'Chaque page possède une icône point d\'interrogation en haut à droite qui ouvre la page wiki correspondante dans son contexte.',
+    },
+    vp: {
+      title: 'Imprimante virtuelle — laissez votre trancheur envoyer directement à Bambuddy',
+      body: 'BambuStudio ou OrcaSlicer peuvent envoyer les impressions à Bambuddy au lieu du cloud Bambu. Choisissez le mode qui correspond à votre configuration :',
+      bridgeTitle: 'Mode pont',
+      bridgeBody: 'Remplacement direct du cloud. Le trancheur envoie à Bambuddy, qui transmet ensuite à l\'imprimante réelle.',
+      queueTitle: 'Mode file d\'attente',
+      queueBody: 'Le trancheur envoie à un collecteur virtuel et Bambuddy met la tâche en file d\'attente pour distribution.',
+      proxyTitle: 'Mode proxy',
+      proxyBody: 'Le trancheur dialogue avec Bambuddy, qui fait transiter avec réécriture complète de MQTT, FTP et RTSP. Idéal pour les configurations multi-trancheurs.',
+      archiveTitle: 'Archive / Revue',
+      archiveBody: 'Le trancheur envoie, Bambuddy stocke mais n\'imprime pas. Utile pour les flux d\'audit et d\'approbation.',
+      ipNote: 'La VP réserve une IP libre sur votre interface de bind afin de ressembler à une vraie imprimante pour le trancheur.',
+      dockerWarning: 'Le mode Docker bridge nécessite une exposition explicite des ports — voir la page wiki Docker sur le découpage des ports passifs FTP.',
+      setUp: 'Configurer une imprimante virtuelle',
+    },
+    slicerApi: {
+      title: 'Trancher depuis des URLs MakerWorld ou votre bibliothèque — sans ouvrir BambuStudio',
+      body: 'Nécessite le conteneur sidecar orca-slicer-api (docker-compose séparé, lien ci-dessous). Bambuddy lui parle en HTTP.',
+      status: 'Statut : encore en maturation en amont — fiable aujourd\'hui pour les tâches mono-filament et mono-plateau, le segfault 3MF multi-filament est en cours de correction en amont.',
+      configure: 'Configurer le sidecar',
+    },
+    externalRoots: {
+      title: 'Monter un partage NAS, un SSD externe ou un disque de projet',
+      body: 'Définissez BAMBUDDY_EXTERNAL_ROOTS dans docker-compose.yml et liez le chemin de l\'hôte. Bambuddy affiche automatiquement le dossier dans le gestionnaire de fichiers.',
+      readOnlyWarning: 'Montez en lecture seule sauf si vous souhaitez expressément que les utilisateurs téléversent vers le partage.',
+    },
+    makerworld: {
+      title: 'Collez n\'importe quelle URL MakerWorld — Bambuddy télécharge le 3MF pour vous',
+      body: 'La recherche dans l\'application a été retirée pour cette version, alors collez l\'URL depuis le site MakerWorld. Les imports respectent la disposition de vos dossiers externes.',
+      tryNow: 'Essayer maintenant',
+    },
+    obico: {
+      title: 'Détection ML d\'échec d\'impression auto-hébergée — sans compte Obico cloud',
+      body: 'Bambuddy dialogue directement avec votre serveur Obico ML auto-hébergé. Activable par imprimante, désactivé par défaut.',
+      smoothing: 'Le lissage et le réglage de la zone morte se trouvent sur le panneau Obico propre à chaque imprimante.',
+    },
+    integrations: {
+      title: 'Home Assistant et webhooks',
+      body: 'Intégration Home Assistant de premier ordre : capteurs pour chaque imprimante (état, température, temps restant, emplacements AMS) et services pour démarrer, mettre en pause ou annuler. Les webhooks se déclenchent sur les événements d\'impression, de file d\'attente et d\'archive.',
+      secret: 'Le secret de signature des webhooks se trouve sous Paramètres → Intégrations.',
+    },
+    tailscale: {
+      title: 'Accéder à Bambuddy depuis partout via votre tailnet',
+      body: 'HTTPS MagicDNS avec Let us Encrypt — Bambuddy demande des certificats via tailscale cert et les sert lui-même.',
+      openSettings: 'Ouvrir les paramètres Tailscale',
+    },
+    notifications: {
+      title: 'Être averti quand les impressions se terminent, échouent ou nécessitent une attention',
+      body: 'Canaux : dans l\'application, push navigateur, Discord, Telegram, Pushover, Gotify, ntfy, e-mail et webhook. Les filtres par événement acheminent les alertes d\'humidité AMS vers Discord, les photos finales vers Telegram, et ainsi de suite.',
+      configure: 'Configurer maintenant',
+    },
+    users: {
+      title: 'Ajoutez des comptes pour le reste de votre équipe',
+      body: 'Chaque utilisateur dispose de ses propres permissions, historique d\'impression et paramètres de notification. Le journal d\'impression indique qui a démarré quelle tâche.',
+      addUser: 'Ajouter un utilisateur',
+    },
+    groups: {
+      title: 'Grouper les utilisateurs par rôle',
+      body: 'Bambuddy est livré avec des groupes par défaut : Administrateurs, Opérateurs, Observateurs. Créez vos propres groupes pour des rôles sur mesure — un compte enfant en lecture seule, un compte conjoint avec accès complet, et ainsi de suite.',
+    },
+    sso: {
+      title: 'Authentification unique et MFA',
+      body: 'OIDC (Authentik, Authelia, Keycloak, Google, GitHub) et SAML 2.0 pour le SSO d\'entreprise. MFA par utilisateur via TOTP. La clé de chiffrement se génère au premier démarrage ; remplaçable par variable d\'environnement pour les flux avec gestionnaire de secrets.',
+      configureOidc: 'Configurer OIDC',
+      configureSaml: 'Configurer SAML',
+      enableMfa: 'Activer MFA sur mon compte',
+    },
+    outro: {
+      title: 'Vous êtes prêt — voici où aller quand quelque chose cloche',
+      system: 'Page Système — version, journaux, paquet de débogage, export d\'assistance.',
+      diagnostic: 'Diagnostic de connexion — l\'imprimante ne se connecte pas, caméra noire, FTP en échec. À ouvrir depuis le menu de la carte imprimante.',
+      logScanner: 'Scanner de santé des journaux — signale les problèmes récurrents à l\'exécution avec des suggestions de correction connues.',
+      wiki: 'Wiki sur wiki.bambuddy.cool — documentation complète des fonctionnalités.',
+      discord: 'Discord — entraide communautaire, plus rapide que GitHub pour les questions d\'usage.',
+      github: 'GitHub Issues — pour les vrais bugs et les demandes de fonctionnalités.',
+      rehome: 'Besoin de revoir cette visite ? Elle se trouve en bas de la barre latérale.',
+    },
+    helpIcon: {
+      openWiki: 'Ouvrir la page wiki de cette section',
+    },
+  },
 };

+ 192 - 0
frontend/src/i18n/locales/it.ts

@@ -6222,4 +6222,196 @@ export default {
     noReadAccess: 'Non hai il permesso di visualizzare le previsioni di inventario.',
     noWriteAccess: 'Non hai il permesso di modificare le impostazioni di previsione.',
   },
+
+  // Tour di benvenuto
+  onboarding: {
+    button: {
+      next: 'Avanti',
+      back: 'Indietro',
+      skip: 'Salta',
+      skipTour: 'Salta il tour',
+      done: 'Fatto',
+      interested: 'Voglio saperne di più',
+      notInterested: 'Mostra più tardi',
+      remindLater: 'Ricordamelo più tardi',
+    },
+    welcome: {
+      title: 'Benvenuto in Bambuddy',
+      body: 'Bambuddy sostituisce il cloud di Bambu Lab con una dashboard locale. I tuoi dati, le stampe, le bobine e i timelapse restano sul tuo hardware. Vuoi fare un tour di cinque minuti?',
+      startTour: 'Inizia il tour',
+      experienced: 'So già come muovermi',
+    },
+    about: {
+      title: 'Cos\'è Bambuddy — e cosa non è',
+      doesTitle: 'Cosa fa Bambuddy',
+      doesBody: 'Sostituisce il cloud di Bambu in locale, traccia l\'AMS e l\'inventario filamento tramite RFID, gestisce una coda di stampa, archivia ogni stampa completata, fornisce una stampante virtuale al tuo slicer, supporta più utenti e include integrazione di prima classe con Home Assistant e Tailscale.',
+      isntTitle: 'Cosa non è Bambuddy',
+      isntBody: 'Non è uno slicer (Bambuddy passa il lavoro a BambuStudio o OrcaSlicer), non è un servizio cloud, non è uno strumento firmware e non è un\'interfaccia per Klipper.',
+      privacy: 'Niente telemetria. Niente account. bambuddy.cool serve solo la documentazione.',
+    },
+    auth: {
+      title: 'Per prima cosa, chiudi la porta d\'ingresso',
+      body: 'Se qualcun altro sulla tua rete, sul tuo tailnet o tramite il tuo reverse proxy può raggiungere questo URL, attiva subito l\'autenticazione. Password, OIDC, SAML e MFA sono già integrati.',
+      severity: 'Bambuddy può controllare le tue stampanti, gestire i tuoi file e leggere i flussi della telecamera — tratta l\'URL come un pannello di amministrazione.',
+      enableNow: 'Attiva l\'autenticazione ora',
+      later: 'Più tardi — questo URL è privato',
+    },
+    addPrinter: {
+      title: 'Aggiungi la tua prima stampante',
+      body: 'Ti servono tre cose: il modello della stampante, il suo indirizzo IP sulla rete e il codice di accesso.',
+      modelLabel: 'Modello',
+      modelHint: 'Rilevato automaticamente tramite discovery, oppure scegli a mano se Bambuddy non riesce a vederla.',
+      ipLabel: 'Indirizzo IP',
+      ipHint: 'Mostrato sullo schermo della stampante in Impostazioni → Rete. Una prenotazione DHCP nel router lo mantiene stabile.',
+      codeLabel: 'Codice di accesso',
+      codeHint: 'Mostrato sullo schermo della stampante; il percorso dipende dal modello — vedi il popover qui sotto.',
+      lanModeWarning: 'Attiva la modalità solo LAN sulla stampante (famiglia X1, H2 e P2S). Senza, le porte MQTT e FTP restano bloccate.',
+      devModeWarning: 'Attiva la Modalità Sviluppatore sullo schermo della stampante — necessaria per il controllo MQTT sulla maggior parte dei modelli.',
+      dockerWarning: 'Utenti Docker bridge: la discovery potrebbe non trovare la stampante. Usa invece il percorso manuale tramite IP.',
+      addViaDiscovery: 'Aggiungi tramite discovery',
+      addManually: 'Aggiungi manualmente via IP',
+    },
+    verifyConnection: {
+      title: 'Assicuriamoci che Bambuddy riesca a comunicare con la stampante',
+      mqttLabel: 'Controllo MQTT (porta 8883)',
+      cameraLabel: 'Flusso telecamera (RTSPS porta 322, solo X1 / H2 / P2S)',
+      ftpLabel: 'Trasferimento file (FTP porta 990)',
+      allGreen: 'Tutti e tre i canali hanno raggiunto la stampante entro 30 secondi.',
+      issuesFound: 'Trovati {{count}} problemi — apri la diagnostica per i dettagli.',
+      runDiagnostic: 'Esegui la diagnostica completa',
+    },
+    tourCard: {
+      title: 'Un breve tour della scheda stampante',
+      status: 'Riga di stato — condizione della stampante, tempo rimanente, fase corrente. Il tuo riepilogo a colpo d\'occhio.',
+      ams: 'Riga AMS — i colori e i tipi degli slot arrivano dall\'RFID, il resto dal tuo inventario; anche il pulsante di asciugatura sta qui.',
+      camera: 'Riquadro telecamera — lo stesso flusso live che usa BambuStudio, ma in locale. Nessun passaggio dal cloud.',
+      controls: 'Comandi — pausa, ripresa, annullamento, luci, ventole — gli stessi controlli che hai sullo schermo della stampante.',
+      customize: 'Clic destro sulla scheda per riordinare i riquadri o nascondere ciò che non ti serve.',
+    },
+    inventoryMode: {
+      title: 'Tieni traccia del tuo filamento',
+      body: 'Bambuddy può tenere il conto delle tue bobine. Scegli una modalità ora — cambiarla in seguito fa perdere i dati storici.',
+      internalTitle: 'Interno (consigliato)',
+      internalBody: 'Inventario integrato, rispecchia l\'AMS, legge l\'RFID e scala automaticamente il peso mentre stampi. Il migliore per la maggior parte degli utenti.',
+      spoolmanTitle: 'Spoolman',
+      spoolmanBody: 'Punta a un\'istanza Spoolman esistente e Bambuddy si sincronizza con essa. Ideale se usi già Spoolman.',
+      noneTitle: 'Disattivato',
+      noneBody: 'Salta del tutto il tracciamento del filamento. Puoi attivarlo in seguito, ma le stampe passate non verranno recuperate.',
+      footgun: 'Cambiare modalità in seguito non migra i dati che hai già inserito.',
+    },
+    addSpool: {
+      title: 'Aggiungi la tua prima bobina',
+      intro: 'Scegli il metodo che preferisci:',
+      rfidTitle: 'Scansione RFID (bobine Bambu)',
+      rfidBody: 'Carica la bobina nell\'AMS — Bambuddy legge il tag RFID in automatico. Niente inserimento manuale.',
+      spoolbuddyTitle: 'SpoolBuddy',
+      spoolbuddyBody: 'Se hai una scatola SpoolBuddy, scansiona un tag RFID scrivibile per bobine non Bambu.',
+      manualTitle: 'Inserisci a mano',
+      manualBody: 'Marca, materiale, colore, peso.',
+      catalog: 'Il catalogo colori integrato copre i principali marchi — i nomi si autocompletano mentre digiti.',
+      addManually: 'Aggiungi a mano',
+      useRfid: 'Usa RFID',
+    },
+    spoolmanSync: {
+      title: 'Indica a Bambuddy dove si trova Spoolman',
+      body: 'Bambuddy importa la tua libreria di bobine esistente e la mantiene sincronizzata. Le scansioni RFID continuano a funzionare — creano nuove bobine direttamente in Spoolman.',
+    },
+    bambuCloud: {
+      title: 'Sincronizza i profili di filamento e stampa da Bambu Lab',
+      body: 'Se conservi profili personalizzati di filamento o stampa in BambuStudio e nel cloud di Bambu, Bambuddy può importarli così che l\'interfaccia web, la coda e la stampante virtuale vedano la stessa libreria.',
+      storage: 'Le credenziali vengono salvate cifrate a riposo e inviate solo all\'API ufficiale di Bambu. Il codice sorgente è aperto.',
+      signIn: 'Accedi a Bambu',
+      useDefaults: 'Salta — usa i valori predefiniti',
+    },
+    sidebar: {
+      title: 'Il resto di Bambuddy in sintesi',
+      queue: 'Coda di stampa — lavori con trascina-e-rilascia, assegnazione automatica alle stampanti libere, asciugatura automatica per PETG e PA.',
+      archives: 'Archivi — ogni stampa completata, con timelapse, foto finale, gcode e 3MF. Ristampa direttamente da qualsiasi riga.',
+      stats: 'Statistiche — ore, filamento per marca, materiale e colore, costo energetico (imposta il prezzo dell\'energia una volta), tasso di successo.',
+      maintenance: 'Manutenzione — usura ugello, tensione cinghia, sostituzione hotend, intervalli di lubrificazione. Voci predefinite più attività personalizzate.',
+      files: 'File — la tua libreria di 3MF, gcode e STL. Carica, tagga, cerca, invia a qualsiasi stampante. Le radici esterne montano condivisioni NAS.',
+      projects: 'Progetti — raggruppa i file in un progetto logico e tieni traccia di quali piatti sono stati stampati.',
+      helpIcon: 'Ogni pagina ha un\'icona a punto interrogativo in alto a destra che apre la pagina wiki corrispondente nel contesto.',
+    },
+    vp: {
+      title: 'Stampante virtuale — lascia che lo slicer invii direttamente a Bambuddy',
+      body: 'BambuStudio o OrcaSlicer possono inviare le stampe a Bambuddy invece che al cloud Bambu. Scegli la modalità adatta alla tua configurazione:',
+      bridgeTitle: 'Modalità ponte',
+      bridgeBody: 'Sostituto diretto del cloud. Lo slicer invia a Bambuddy, Bambuddy inoltra alla stampante reale.',
+      queueTitle: 'Modalità coda',
+      queueBody: 'Lo slicer invia a un raccoglitore virtuale e Bambuddy mette il lavoro in coda per l\'assegnazione.',
+      proxyTitle: 'Modalità proxy',
+      proxyBody: 'Lo slicer parla con Bambuddy, che fa passare tutto con riscrittura completa di MQTT, FTP e RTSP. Ideale per configurazioni con più slicer.',
+      archiveTitle: 'Archivio / Revisione',
+      archiveBody: 'Lo slicer invia, Bambuddy salva ma non stampa. Utile per flussi di audit e approvazione.',
+      ipNote: 'La VP riserva un IP libero sulla tua interfaccia di bind così da apparire allo slicer come una stampante reale.',
+      dockerWarning: 'La modalità Docker bridge richiede l\'esposizione esplicita delle porte — vedi la pagina wiki di Docker per la suddivisione delle porte passive FTP.',
+      setUp: 'Configura una stampante virtuale',
+    },
+    slicerApi: {
+      title: 'Affetta da URL di MakerWorld o dalla tua libreria — senza aprire BambuStudio',
+      body: 'Richiede il container sidecar orca-slicer-api (docker-compose separato, link sotto). Bambuddy ci parla via HTTP.',
+      status: 'Stato: ancora in maturazione a monte — solido oggi per lavori a filamento singolo e piatto singolo, il segfault sui 3MF multi-filamento è in corso di patch a monte.',
+      configure: 'Configura il sidecar',
+    },
+    externalRoots: {
+      title: 'Monta una condivisione NAS, un SSD esterno o un\'unità di progetto',
+      body: 'Imposta BAMBUDDY_EXTERNAL_ROOTS in docker-compose.yml e fai il bind-mount del percorso host. Bambuddy mostra la cartella in automatico nel gestore file.',
+      readOnlyWarning: 'Monta in sola lettura, a meno che tu non voglia esplicitamente che gli utenti scrivano sulla condivisione.',
+    },
+    makerworld: {
+      title: 'Incolla qualsiasi URL di MakerWorld — Bambuddy scarica il 3MF al posto tuo',
+      body: 'La ricerca in-app è stata rimandata per questa versione, quindi incolla l\'URL dal sito MakerWorld. Le importazioni rispettano la tua struttura di cartelle esterne.',
+      tryNow: 'Provalo ora',
+    },
+    obico: {
+      title: 'Rilevamento ML di errori di stampa auto-ospitato — senza account cloud Obico',
+      body: 'Bambuddy parla direttamente con il tuo server Obico ML auto-ospitato. Attivabile per stampante, disattivato di default.',
+      smoothing: 'La regolazione di smoothing e zona morta si trova nel pannello Obico specifico della stampante.',
+    },
+    integrations: {
+      title: 'Home Assistant e webhook',
+      body: 'Integrazione di prima classe con Home Assistant: sensori per ogni stampante (stato, temperatura, tempo rimanente, slot AMS) e servizi per avviare, mettere in pausa o annullare. I webhook si attivano su eventi di stampa, di coda e di archivio.',
+      secret: 'Il segreto di firma dei webhook si trova in Impostazioni → Integrazioni.',
+    },
+    tailscale: {
+      title: 'Accedi a Bambuddy da qualunque luogo tramite il tuo tailnet',
+      body: 'MagicDNS HTTPS con Let us Encrypt — Bambuddy richiede i certificati tramite tailscale cert e li serve da sé.',
+      openSettings: 'Apri le impostazioni di Tailscale',
+    },
+    notifications: {
+      title: 'Fatti avvisare quando le stampe finiscono, falliscono o richiedono attenzione',
+      body: 'Canali: in-app, push del browser, Discord, Telegram, Pushover, Gotify, ntfy, email e webhook. I filtri per evento instradano gli avvisi di umidità AMS verso Discord, le foto finali verso Telegram, e così via.',
+      configure: 'Configura ora',
+    },
+    users: {
+      title: 'Aggiungi account per il resto della tua squadra',
+      body: 'Ogni utente ha permessi, cronologia di stampa e impostazioni di notifica propri. Il registro di stampa mostra chi ha avviato quale lavoro.',
+      addUser: 'Aggiungi un utente',
+    },
+    groups: {
+      title: 'Raggruppa gli utenti per ruolo',
+      body: 'Bambuddy include gruppi predefiniti: Amministratori, Operatori, Visualizzatori. Crea i tuoi gruppi per ruoli personalizzati — un account per bambini in sola lettura, un partner con accesso completo, e così via.',
+    },
+    sso: {
+      title: 'Single sign-on e MFA',
+      body: 'OIDC (Authentik, Authelia, Keycloak, Google, GitHub) e SAML 2.0 per il SSO aziendale. MFA per utente tramite TOTP. La chiave di cifratura viene generata al primo avvio; sovrascrivila tramite variabile d\'ambiente per flussi con gestore di segreti.',
+      configureOidc: 'Configura OIDC',
+      configureSaml: 'Configura SAML',
+      enableMfa: 'Attiva MFA sul mio account',
+    },
+    outro: {
+      title: 'È tutto pronto — ecco dove andare se qualcosa non va',
+      system: 'Pagina Sistema — versione, log, pacchetto di debug, esportazione di supporto.',
+      diagnostic: 'Diagnostica connessione — la stampante non si collega, telecamera nera, FTP non funziona. Si apre dal menu della scheda stampante.',
+      logScanner: 'Scanner di salute dei log — segnala problemi ricorrenti a runtime con suggerimenti di soluzione noti.',
+      wiki: 'Wiki su wiki.bambuddy.cool — documentazione completa delle funzioni.',
+      discord: 'Discord — aiuto dalla community, più veloce di GitHub per le domande d\'uso.',
+      github: 'GitHub Issues — per i bug veri e le richieste di funzioni.',
+      rehome: 'Vuoi rivedere questo tour? Si trova in fondo alla barra laterale.',
+    },
+    helpIcon: {
+      openWiki: 'Apri la pagina wiki di questa sezione',
+    },
+  },
 };

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

@@ -6234,4 +6234,196 @@ export default {
     noReadAccess: '在庫予測を閲覧する権限がありません。',
     noWriteAccess: '予測設定を変更する権限がありません。',
   },
+
+  // オンボーディングツアー
+  onboarding: {
+    button: {
+      next: '次へ',
+      back: '戻る',
+      skip: 'スキップ',
+      skipTour: 'ツアーをスキップ',
+      done: '完了',
+      interested: '詳しく知りたい',
+      notInterested: '後で表示',
+      remindLater: '後で通知',
+    },
+    welcome: {
+      title: 'Bambuddyへようこそ',
+      body: 'BambuddyはBambu Labのクラウドをローカルファーストのダッシュボードに置き換えます。データ、印刷、スプール、タイムラプスはすべてご自身のハードウェアに残ります。5分間のツアーをご覧になりますか?',
+      startTour: 'ツアーを開始',
+      experienced: '使い方はもう知っています',
+    },
+    about: {
+      title: 'Bambuddyとは — そしてBambuddyではないもの',
+      doesTitle: 'Bambuddyができること',
+      doesBody: 'Bambuクラウドをローカルに置き換え、AMSとフィラメント在庫をRFIDで追跡し、印刷キューを動かし、完了した印刷をすべてアーカイブし、スライサー向けに仮想プリンターを提供し、複数ユーザーに対応し、Home AssistantとTailscaleの一級統合を備えます。',
+      isntTitle: 'Bambuddyではないもの',
+      isntBody: 'スライサーではありません(BambuddyはBambuStudioまたはOrcaSlicerに引き渡します)。クラウドサービスでも、ファームウェアツールでも、Klipper UIでもありません。',
+      privacy: 'テレメトリーなし。アカウントなし。bambuddy.coolはドキュメントを配信するだけです。',
+    },
+    auth: {
+      title: 'まずは玄関の鍵をかけましょう',
+      body: 'ネットワーク、tailnet、リバースプロキシ経由で他の誰かがこのURLに到達できる場合は、今すぐ認証を有効にしてください。パスワード、OIDC、SAML、MFAはすべて組み込まれています。',
+      severity: 'Bambuddyはプリンターを制御し、ファイルを管理し、カメラ映像を読み取ることができます — このURLは管理者パネルと同じように扱ってください。',
+      enableNow: '今すぐ認証を有効化',
+      later: '後で — このURLは非公開です',
+    },
+    addPrinter: {
+      title: '最初のプリンターを追加',
+      body: '必要なものは3つです: プリンターの機種、ネットワーク上のIPアドレス、アクセスコードです。',
+      modelLabel: '機種',
+      modelHint: '検出により自動取得されます。Bambuddyがプリンターを認識できない場合は手動で選択してください。',
+      ipLabel: 'IPアドレス',
+      ipHint: 'プリンターの液晶画面の「設定 → ネットワーク」に表示されます。ルーターでDHCP予約をしておくと安定します。',
+      codeLabel: 'アクセスコード',
+      codeHint: 'プリンターの液晶画面に表示されます。経路は機種によって異なります — 下のポップオーバーをご覧ください。',
+      lanModeWarning: 'プリンター側でLAN専用モードを有効化してください(X1、H2、P2Sファミリー)。有効にしないとMQTTとFTPのポートがブロックされたままになります。',
+      devModeWarning: 'プリンター液晶で開発者モードを有効化してください — ほとんどの機種でMQTT制御に必要です。',
+      dockerWarning: 'Dockerブリッジ利用者へ: 検出ではプリンターが見つからないことがあります。代わりに手動IP指定の手順をご利用ください。',
+      addViaDiscovery: '検出から追加',
+      addManually: 'IPを指定して手動で追加',
+    },
+    verifyConnection: {
+      title: 'Bambuddyからプリンターに到達できるか確認しましょう',
+      mqttLabel: 'MQTT制御(ポート8883)',
+      cameraLabel: 'カメラストリーム(RTSPSポート322、X1 / H2 / P2Sのみ)',
+      ftpLabel: 'ファイル転送(FTPポート990)',
+      allGreen: '3つのチャネルすべてが30秒以内にプリンターに到達しました。',
+      issuesFound: '{{count}}件の問題が見つかりました — 詳細は診断を開いてください。',
+      runDiagnostic: '完全な診断を実行',
+    },
+    tourCard: {
+      title: 'プリンターカードの簡単なツアー',
+      status: 'ステータス行 — プリンターの状態、残り時間、現在のステージ。ひと目で全体を把握できます。',
+      ams: 'AMS行 — スロットの色と種類はRFIDから、それ以外は在庫から取得されます。乾燥ボタンもここにあります。',
+      camera: 'カメラタイル — BambuStudioと同じライブストリームをローカルで表示。クラウド経由ではありません。',
+      controls: '操作 — 一時停止、再開、キャンセル、ライト、ファン — プリンター液晶と同じ操作が行えます。',
+      customize: 'カードを右クリックするとタイルを並び替えたり、不要な項目を非表示にできます。',
+    },
+    inventoryMode: {
+      title: 'フィラメントを管理',
+      body: 'Bambuddyはスプールを追跡できます。今ここでモードを選んでください — 後から切り替えると履歴データが失われます。',
+      internalTitle: '内蔵(推奨)',
+      internalBody: '内蔵の在庫機能。AMSをミラーし、RFIDを読み取り、印刷に合わせて重量を自動で減算します。ほとんどのユーザーに最適です。',
+      spoolmanTitle: 'Spoolman',
+      spoolmanBody: '既存のSpoolmanインスタンスを指定すると、Bambuddyがそこから同期します。すでにSpoolmanを運用している方に最適です。',
+      noneTitle: 'オフ',
+      noneBody: 'フィラメント追跡を完全にスキップします。後から有効化できますが、過去の印刷はさかのぼって記録されません。',
+      footgun: '後でモードを切り替えても、すでに入力したデータは移行されません。',
+    },
+    addSpool: {
+      title: '最初のスプールを追加',
+      intro: 'お好みの方法を選んでください:',
+      rfidTitle: 'RFIDスキャン(Bambu製スプール)',
+      rfidBody: 'スプールをAMSにセットすると — BambuddyがRFIDタグを自動で読み取ります。手動入力は不要です。',
+      spoolbuddyTitle: 'SpoolBuddy',
+      spoolbuddyBody: 'SpoolBuddyボックスをお持ちなら、Bambu以外のスプール用に書き込み可能なRFIDタグをスキャンできます。',
+      manualTitle: '手入力',
+      manualBody: 'ブランド、素材、色、重量。',
+      catalog: '内蔵のカラーカタログは主要ブランドを網羅しています — 入力に合わせて名前が自動補完されます。',
+      addManually: '手入力で追加',
+      useRfid: 'RFIDを使う',
+    },
+    spoolmanSync: {
+      title: 'Spoolmanの場所をBambuddyに教える',
+      body: 'Bambuddyは既存のスプールライブラリを取り込み、同期し続けます。RFIDスキャンも引き続き機能し、Spoolman側に新しいスプールが作成されます。',
+    },
+    bambuCloud: {
+      title: 'Bambu Labからフィラメントと印刷プロファイルを同期',
+      body: 'BambuStudioとBambuクラウドにカスタムフィラメント・印刷プロファイルを保管している場合、Bambuddyがそれらを取り込み、Web UI・キュー・仮想プリンターのすべてで同じライブラリを参照できるようにします。',
+      storage: '認証情報は暗号化して保存され、公式のBambu APIにのみ送信されます。ソースコードは公開されています。',
+      signIn: 'Bambuにサインイン',
+      useDefaults: 'スキップ — 内蔵の既定値を使う',
+    },
+    sidebar: {
+      title: 'Bambuddyの残りの機能をひと目で',
+      queue: '印刷キュー — ドラッグ&ドロップでジョブを並び替え、空きプリンターへ自動ディスパッチ、PETGとPA向けの自動乾燥。',
+      archives: 'アーカイブ — 完了したすべての印刷を、タイムラプス、完了写真、gcode、3MFと共に保存。任意の行からそのまま再印刷できます。',
+      stats: '統計 — 時間、ブランド・素材・色別のフィラメント、電気代(電気料金を一度設定するだけ)、成功率。',
+      maintenance: 'メンテナンス — ノズルの摩耗、ベルト張力、ホットエンド交換、グリスアップ周期。組み込みの既定タスクに加えて独自タスクも追加可能。',
+      files: 'ファイル — 3MF、gcode、STLのライブラリ。アップロード、タグ付け、検索、任意のプリンターへ送信。外部ルートでNAS共有をマウントできます。',
+      projects: 'プロジェクト — ファイルを論理的なプロジェクトにまとめ、どのプレートが印刷済みかを管理します。',
+      helpIcon: 'すべてのページの右上にあるクエスチョンマークアイコンから、その文脈に合ったWikiページを直接開けます。',
+    },
+    vp: {
+      title: '仮想プリンター — スライサーから直接Bambuddyへ送信',
+      body: 'BambuStudioまたはOrcaSlicerは、Bambuクラウドの代わりにBambuddyへ印刷を送信できます。お使いの構成に合うモードを選んでください:',
+      bridgeTitle: 'ブリッジモード',
+      bridgeBody: 'クラウドをそのまま置き換えます。スライサーはBambuddyへ送信し、Bambuddyが実機プリンターへ転送します。',
+      queueTitle: 'キューモード',
+      queueBody: 'スライサーは仮想の収集口へ送信し、Bambuddyがジョブをキューに入れてディスパッチします。',
+      proxyTitle: 'プロキシモード',
+      proxyBody: 'スライサーはBambuddyと通信し、BambuddyがMQTT・FTP・RTSPの書き換えを伴って透過的に中継します。複数スライサー構成に最適です。',
+      archiveTitle: 'アーカイブ / レビュー',
+      archiveBody: 'スライサーが送信し、Bambuddyは保存しますが印刷はしません。監査や承認ワークフローに便利です。',
+      ipNote: 'VPはバインドインターフェース上の空きIPを取得し、スライサーから見ると実機プリンターのように振る舞います。',
+      dockerWarning: 'Dockerブリッジモードでは明示的なポート公開が必要です — FTPパッシブポートの分割についてはDockerのWikiページをご覧ください。',
+      setUp: '仮想プリンターをセットアップ',
+    },
+    slicerApi: {
+      title: 'MakerWorldのURLやライブラリからスライス — BambuStudioを開かずに',
+      body: 'orca-slicer-apiサイドカーコンテナが必要です(別のdocker-compose、リンクは下記)。BambuddyはHTTP経由でやり取りします。',
+      status: 'ステータス: 上流ではまだ成熟段階 — 現状はシングルフィラメントと単一プレートのジョブで安定しています。マルチフィラメント3MFのセグフォルトは上流で修正中です。',
+      configure: 'サイドカーを設定',
+    },
+    externalRoots: {
+      title: 'NAS共有、外付けSSD、プロジェクト用ドライブをマウント',
+      body: 'docker-compose.ymlでBAMBUDDY_EXTERNAL_ROOTSを設定し、ホストパスをバインドマウントしてください。Bambuddyがファイルマネージャーに自動でフォルダーを表示します。',
+      readOnlyWarning: 'ユーザーに共有へ書き戻させたい場合を除き、読み取り専用でマウントしてください。',
+    },
+    makerworld: {
+      title: '任意のMakerWorld URLを貼り付け — Bambuddyが3MFをダウンロード',
+      body: '今回のリリースではアプリ内検索を見送ったため、MakerWorldサイトからURLを貼り付けてください。インポートは外部フォルダー構成に従います。',
+      tryNow: '今すぐ試す',
+    },
+    obico: {
+      title: 'セルフホストMLによる印刷失敗検知 — Obicoクラウドアカウント不要',
+      body: 'Bambuddyはセルフホストしたお客様のObico MLサーバーと直接通信します。プリンターごとにオプトイン、既定ではオフです。',
+      smoothing: '平滑化とデッドゾーンの調整は、プリンターごとのObicoパネルにあります。',
+    },
+    integrations: {
+      title: 'Home Assistantとwebhook',
+      body: '一級のHome Assistant統合: プリンターごとのセンサー(状態、温度、残り時間、AMSスロット)と、開始・一時停止・キャンセル用のサービスを提供。印刷、キュー、アーカイブの各イベントでwebhookが発火します。',
+      secret: 'webhookの署名シークレットは「設定 → 統合」にあります。',
+    },
+    tailscale: {
+      title: 'tailnet経由でどこからでもBambuddyにアクセス',
+      body: 'Let\'s EncryptによるMagicDNS HTTPS — Bambuddyはtailscale certで証明書を取得し、自前で配信します。',
+      openSettings: 'Tailscale設定を開く',
+    },
+    notifications: {
+      title: '印刷が完了・失敗・要対応になったときに通知を受け取る',
+      body: 'チャネル: アプリ内、ブラウザプッシュ、Discord、Telegram、Pushover、Gotify、ntfy、メール、webhook。イベント別フィルターで、AMS湿度警告はDiscordへ、完了写真はTelegramへ、というように振り分けられます。',
+      configure: '今すぐ設定',
+    },
+    users: {
+      title: 'チームメンバー用のアカウントを追加',
+      body: 'ユーザーごとに権限、印刷履歴、通知設定を持てます。印刷ログには、どのジョブを誰が開始したかが表示されます。',
+      addUser: 'ユーザーを追加',
+    },
+    groups: {
+      title: 'ユーザーを役割でグループ化',
+      body: 'Bambuddyには既定のグループが用意されています: 管理者、オペレーター、閲覧者。読み取り専用の子ども用アカウント、フルアクセスのパートナーなど、独自のグループも作成できます。',
+    },
+    sso: {
+      title: 'シングルサインオンとMFA',
+      body: '組織SSO向けにOIDC(Authentik、Authelia、Keycloak、Google、GitHub)とSAML 2.0に対応。ユーザーごとのMFAはTOTPで提供。暗号鍵は初回起動時に自動生成され、シークレットマネージャー運用向けに環境変数で上書きできます。',
+      configureOidc: 'OIDCを設定',
+      configureSaml: 'SAMLを設定',
+      enableMfa: '自分のアカウントでMFAを有効化',
+    },
+    outro: {
+      title: '準備完了 — 問題が起きたらここをご覧ください',
+      system: 'システムページ — バージョン、ログ、デバッグバンドル、サポート用エクスポート。',
+      diagnostic: '接続診断 — プリンターがつながらない、カメラが真っ黒、FTPが失敗するなどに対応。プリンターカードのメニューから開けます。',
+      logScanner: 'ログヘルススキャナー — 再発する実行時の問題を、既知の修正方法と共に報告します。',
+      wiki: 'wiki.bambuddy.coolのWiki — 機能の完全なドキュメント。',
+      discord: 'Discord — コミュニティのサポート。使い方の質問はGitHubより速く答えが返ってきます。',
+      github: 'GitHub Issues — 実際のバグや機能要望はこちらへ。',
+      rehome: 'もう一度ツアーを見たくなったら? サイドバーの下部にあります。',
+    },
+    helpIcon: {
+      openWiki: 'このセクションのウィキページを開く',
+    },
+  },
 };

+ 191 - 1
frontend/src/i18n/locales/ko.ts

@@ -5859,5 +5859,195 @@ export default {
         fail: '이 가상 프린터의 TLS 인증서가 없습니다. Bambuddy 데이터 디렉터리가 쓰기 가능한지 확인하세요.'
       }
     }
-  }
+  },
+  onboarding: {
+    button: {
+      next: '계속',
+      back: '뒤로',
+      skip: '건너뛰기',
+      skipTour: '둘러보기 건너뛰기',
+      done: '완료',
+      interested: '더 알아보기',
+      notInterested: '나중에 보기',
+      remindLater: '나중에 다시 알리기',
+    },
+    welcome: {
+      title: 'Bambuddy에 오신 것을 환영합니다',
+      body: 'Bambuddy는 Bambu Lab 클라우드를 로컬 우선 대시보드로 대체합니다. 데이터, 인쇄, 스풀, 타임랩스가 모두 사용자의 하드웨어에 남아 있습니다. 5분짜리 둘러보기를 시작할까요?',
+      startTour: '둘러보기 시작',
+      experienced: '이미 사용법을 알고 있습니다',
+    },
+    about: {
+      title: 'Bambuddy가 무엇이고, 무엇이 아닌지',
+      doesTitle: 'Bambuddy가 하는 일',
+      doesBody: 'Bambu 클라우드를 로컬에서 대체하고, RFID로 AMS 및 필라멘트 재고를 추적하며, 인쇄 큐를 운영하고, 완료된 모든 인쇄를 아카이브하며, 슬라이서에 가상 프린터를 제공하고, 다중 사용자를 지원하며, 일급 Home Assistant 및 Tailscale 통합을 함께 제공합니다.',
+      isntTitle: 'Bambuddy가 아닌 것',
+      isntBody: '슬라이서가 아니며(Bambuddy는 BambuStudio 또는 OrcaSlicer로 작업을 넘깁니다), 클라우드 서비스가 아니고, 펌웨어 도구가 아니며, Klipper UI도 아닙니다.',
+      privacy: '텔레메트리 없음. 계정 없음. bambuddy.cool은 문서만 제공합니다.',
+    },
+    auth: {
+      title: '먼저 현관문부터 잠그세요',
+      body: '네트워크, 테일넷, 또는 리버스 프록시를 통해 다른 누군가가 이 URL에 접근할 수 있다면 지금 바로 인증을 켜세요. 비밀번호, OIDC, SAML, MFA가 모두 기본 내장되어 있습니다.',
+      severity: 'Bambuddy는 프린터를 제어하고, 파일을 관리하며, 카메라 영상을 볼 수 있습니다 — 이 URL을 관리자 패널처럼 다루세요.',
+      enableNow: '지금 인증 활성화',
+      later: '나중에 — 이 URL은 비공개입니다',
+    },
+    addPrinter: {
+      title: '첫 번째 프린터 추가',
+      body: '세 가지가 필요합니다: 프린터 모델, 네트워크상의 IP 주소, 그리고 액세스 코드.',
+      modelLabel: '모델',
+      modelHint: '검색을 통해 자동 감지되거나, Bambuddy가 프린터를 찾지 못하면 수동으로 선택할 수 있습니다.',
+      ipLabel: 'IP 주소',
+      ipHint: '프린터 LCD의 설정 → 네트워크에서 확인할 수 있습니다. 라우터의 DHCP 예약을 사용하면 주소가 고정됩니다.',
+      codeLabel: '액세스 코드',
+      codeHint: '프린터 LCD에 표시되며 경로는 모델에 따라 다릅니다 — 아래 팝오버를 참고하세요.',
+      lanModeWarning: '프린터(X1, H2, P2S 계열)에서 LAN 전용 모드를 활성화하세요. 그렇지 않으면 MQTT 및 FTP 포트가 차단된 상태로 남습니다.',
+      devModeWarning: '프린터 LCD에서 개발자 모드를 활성화하세요 — 대부분의 모델에서 MQTT 제어에 필요합니다.',
+      dockerWarning: 'Docker 브리지 사용자: 검색이 프린터를 찾지 못할 수 있습니다. 대신 IP를 수동으로 입력하는 방법을 사용하세요.',
+      addViaDiscovery: '검색으로 추가',
+      addManually: 'IP로 수동 추가',
+    },
+    verifyConnection: {
+      title: 'Bambuddy가 프린터와 통신할 수 있는지 확인합니다',
+      mqttLabel: 'MQTT 제어 (포트 8883)',
+      cameraLabel: '카메라 스트림 (RTSPS 포트 322, X1 / H2 / P2S 전용)',
+      ftpLabel: '파일 전송 (FTP 포트 990)',
+      allGreen: '세 채널 모두 30초 안에 프린터에 도달했습니다.',
+      issuesFound: '{{count}}개의 문제가 발견되었습니다 — 자세한 내용은 진단을 열어보세요.',
+      runDiagnostic: '전체 진단 실행',
+    },
+    tourCard: {
+      title: '프린터 카드 빠르게 둘러보기',
+      status: '상태 행 — 프린터 상태, 예상 완료 시간, 현재 단계. 한눈에 보는 상태 정보입니다.',
+      ams: 'AMS 행 — 슬롯 색상과 종류는 RFID에서 가져오고 나머지는 재고에서 가져옵니다. 건조 버튼도 여기에 있습니다.',
+      camera: '카메라 타일 — BambuStudio가 사용하는 것과 같은 라이브 스트림을 로컬에서 보여줍니다. 클라우드를 거치지 않습니다.',
+      controls: '컨트롤 — 일시정지, 재개, 취소, 조명, 팬 — 프린터 LCD에서 사용하는 것과 같은 제어입니다.',
+      customize: '카드를 우클릭하면 타일을 재배치하거나 필요 없는 항목을 숨길 수 있습니다.',
+    },
+    inventoryMode: {
+      title: '필라멘트 추적',
+      body: 'Bambuddy가 스풀을 관리해 드릴 수 있습니다. 지금 모드를 선택하세요 — 나중에 모드를 바꾸면 이력 데이터가 사라집니다.',
+      internalTitle: '내장 (권장)',
+      internalBody: '내장된 재고가 AMS를 미러링하고 RFID를 읽으며, 인쇄할 때 무게가 자동으로 차감됩니다. 대부분의 사용자에게 적합합니다.',
+      spoolmanTitle: 'Spoolman',
+      spoolmanBody: '기존 Spoolman 인스턴스를 지정하면 Bambuddy가 그곳에서 동기화합니다. 이미 Spoolman을 운영 중이라면 적합합니다.',
+      noneTitle: '끔',
+      noneBody: '필라멘트 추적을 완전히 건너뜁니다. 나중에 켤 수 있지만 과거 인쇄 데이터는 채워지지 않습니다.',
+      footgun: '나중에 모드를 변경해도 이미 입력한 데이터는 이전되지 않습니다.',
+    },
+    addSpool: {
+      title: '첫 번째 스풀 추가',
+      intro: '자신에게 맞는 방법을 선택하세요:',
+      rfidTitle: 'RFID 스캔 (Bambu 스풀)',
+      rfidBody: '스풀을 AMS에 장착하면 Bambuddy가 RFID 태그를 자동으로 읽습니다. 수동 입력이 필요 없습니다.',
+      spoolbuddyTitle: 'SpoolBuddy',
+      spoolbuddyBody: 'SpoolBuddy 박스를 갖고 있다면, 비-Bambu 스풀을 위한 쓰기 가능 RFID 태그를 스캔할 수 있습니다.',
+      manualTitle: '직접 입력',
+      manualBody: '브랜드, 재료, 색상, 무게.',
+      catalog: '내장된 색상 카탈로그가 주요 브랜드를 포괄하며, 입력하는 동안 이름이 자동 완성됩니다.',
+      addManually: '직접 추가',
+      useRfid: 'RFID 사용',
+    },
+    spoolmanSync: {
+      title: 'Spoolman이 어디에 있는지 Bambuddy에 알려주세요',
+      body: 'Bambuddy가 기존 스풀 라이브러리를 가져와 동기화 상태를 유지합니다. RFID 스캔은 그대로 작동하며 — 새 스풀을 Spoolman 안에 생성합니다.',
+    },
+    bambuCloud: {
+      title: 'Bambu Lab에서 필라멘트 및 인쇄 프로파일 동기화',
+      body: 'BambuStudio와 Bambu 클라우드에 사용자 정의 필라멘트나 인쇄 프로파일을 보관하고 있다면, Bambuddy가 이를 가져와 웹 UI, 큐, 가상 프린터가 같은 라이브러리를 보도록 만들 수 있습니다.',
+      storage: '자격 증명은 저장 시 암호화되며 공식 Bambu API로만 전송됩니다. 소스는 공개되어 있습니다.',
+      signIn: 'Bambu에 로그인',
+      useDefaults: '건너뛰기 — 내장 기본값 사용',
+    },
+    sidebar: {
+      title: '한눈에 보는 Bambuddy의 나머지 기능',
+      queue: '인쇄 큐 — 드래그 앤 드롭으로 작업 관리, 유휴 프린터로 자동 배정, PETG 및 PA용 자동 건조.',
+      archives: '아카이브 — 완료된 모든 인쇄가 타임랩스, 완료 사진, gcode, 3MF와 함께 보관됩니다. 어떤 행에서든 바로 재인쇄할 수 있습니다.',
+      stats: '통계 — 시간, 브랜드/재료/색상별 필라멘트 사용량, 에너지 비용(전기 요금을 한 번만 설정), 성공률.',
+      maintenance: '유지보수 — 노즐 마모, 벨트 장력, 핫엔드 교체, 윤활 주기. 기본 항목과 사용자 정의 작업을 함께 제공합니다.',
+      files: '파일 — 3MF, gcode, STL 라이브러리. 업로드, 태그, 검색, 어느 프린터에든 전송. 외부 루트로 NAS 공유를 마운트할 수 있습니다.',
+      projects: '프로젝트 — 파일을 논리적인 프로젝트로 묶고 어떤 플레이트가 인쇄되었는지 추적합니다.',
+      helpIcon: '모든 페이지의 오른쪽 위에는 물음표 아이콘이 있으며, 해당 위키 페이지를 컨텍스트에 맞게 열어줍니다.',
+    },
+    vp: {
+      title: '가상 프린터 — 슬라이서가 곧바로 Bambuddy로 전송',
+      body: 'BambuStudio나 OrcaSlicer가 인쇄를 Bambu 클라우드 대신 Bambuddy로 보낼 수 있습니다. 환경에 맞는 모드를 선택하세요:',
+      bridgeTitle: '브리지 모드',
+      bridgeBody: '드롭인 클라우드 대체. 슬라이서가 Bambuddy로 보내면 Bambuddy가 실제 프린터로 전달합니다.',
+      queueTitle: '큐 모드',
+      queueBody: '슬라이서가 가상 수집기로 보내면 Bambuddy가 작업을 큐에 넣어 배정합니다.',
+      proxyTitle: '프록시 모드',
+      proxyBody: '슬라이서가 Bambuddy와 통신하고, Bambuddy는 MQTT, FTP, RTSP를 모두 재작성하여 그대로 통과시킵니다. 다중 슬라이서 환경에 가장 적합합니다.',
+      archiveTitle: '아카이브 / 검토',
+      archiveBody: '슬라이서가 보내면 Bambuddy가 저장만 하고 인쇄하지 않습니다. 감사 및 승인 워크플로에 유용합니다.',
+      ipNote: 'VP는 바인드 인터페이스의 빈 IP를 차지하여 슬라이서에게 실제 프린터처럼 보이도록 합니다.',
+      dockerWarning: 'Docker 브리지 모드에서는 포트를 명시적으로 노출해야 합니다 — FTP 패시브 포트 분할에 대해서는 Docker 위키 페이지를 참고하세요.',
+      setUp: '가상 프린터 설정',
+    },
+    slicerApi: {
+      title: 'BambuStudio를 열지 않고 MakerWorld URL이나 라이브러리에서 슬라이싱',
+      body: 'orca-slicer-api 사이드카 컨테이너가 필요합니다(별도 docker-compose, 아래 링크). Bambuddy는 HTTP로 이와 통신합니다.',
+      status: '상태: 상위에서 아직 성숙 중 — 오늘 기준으로 단일 필라멘트 및 단일 플레이트 작업에서는 안정적이며, 멀티 필라멘트 3MF 세그폴트는 상위에서 수정 중입니다.',
+      configure: '사이드카 구성',
+    },
+    externalRoots: {
+      title: 'NAS 공유, 외장 SSD 또는 프로젝트 드라이브 마운트',
+      body: 'docker-compose.yml에 BAMBUDDY_EXTERNAL_ROOTS를 설정하고 호스트 경로를 바인드 마운트하세요. Bambuddy가 파일 관리자에 해당 폴더를 자동으로 표시합니다.',
+      readOnlyWarning: '사용자가 공유에 다시 업로드하도록 명시적으로 허용하지 않는 한 읽기 전용으로 마운트하세요.',
+    },
+    makerworld: {
+      title: 'MakerWorld URL을 붙여 넣으면 Bambuddy가 3MF를 대신 내려받습니다',
+      body: '이번 릴리스에서는 앱 내 검색이 빠졌으므로 MakerWorld 사이트의 URL을 붙여 넣으세요. 가져오기는 외부 폴더 레이아웃을 따릅니다.',
+      tryNow: '지금 사용해 보기',
+    },
+    obico: {
+      title: '셀프 호스팅 ML 인쇄 실패 감지 — Obico 클라우드 계정 불필요',
+      body: 'Bambuddy가 셀프 호스팅 Obico ML 서버와 직접 통신합니다. 프린터별로 선택해서 켤 수 있으며 기본적으로 꺼져 있습니다.',
+      smoothing: '스무딩 및 데드존 조정은 프린터별 Obico 패널에 있습니다.',
+    },
+    integrations: {
+      title: 'Home Assistant와 웹훅',
+      body: '일급 Home Assistant 통합: 프린터별 센서(상태, 온도, 예상 완료 시간, AMS 슬롯)와 시작, 일시정지, 취소 서비스를 제공합니다. 웹훅은 인쇄 이벤트, 큐 이벤트, 아카이브 이벤트에서 발생합니다.',
+      secret: '웹훅 서명 비밀은 설정 → 통합에 있습니다.',
+    },
+    tailscale: {
+      title: '테일넷을 통해 어디서든 Bambuddy에 접속',
+      body: 'Let us Encrypt를 사용한 MagicDNS HTTPS — Bambuddy가 tailscale cert로 인증서를 요청하고 직접 제공합니다.',
+      openSettings: 'Tailscale 설정 열기',
+    },
+    notifications: {
+      title: '인쇄가 끝나거나 실패하거나 주의가 필요할 때 알림 받기',
+      body: '채널: 인앱, 브라우저 푸시, Discord, Telegram, Pushover, Gotify, ntfy, 이메일, 웹훅. 이벤트별 필터를 통해 AMS 습도 경고는 Discord로, 완료 사진은 Telegram으로 보내는 식으로 라우팅할 수 있습니다.',
+      configure: '지금 구성',
+    },
+    users: {
+      title: '나머지 팀원을 위한 계정 추가',
+      body: '모든 사용자에게는 각자의 권한, 인쇄 이력, 알림 설정이 주어집니다. 인쇄 로그에는 어떤 작업을 누가 시작했는지 표시됩니다.',
+      addUser: '사용자 추가',
+    },
+    groups: {
+      title: '역할별로 사용자 그룹화',
+      body: 'Bambuddy는 기본 그룹과 함께 제공됩니다: 관리자, 운영자, 뷰어. 사용자 정의 역할을 위해 직접 그룹을 만들 수도 있습니다 — 읽기 전용 자녀 계정, 전체 권한을 가진 파트너 등.',
+    },
+    sso: {
+      title: '싱글 사인온과 MFA',
+      body: '조직 SSO를 위한 OIDC(Authentik, Authelia, Keycloak, Google, GitHub)와 SAML 2.0. TOTP를 통한 사용자별 MFA. 암호화 키는 첫 실행 시 자동 생성되며, 비밀 관리 워크플로를 위해 환경 변수로 재정의할 수 있습니다.',
+      configureOidc: 'OIDC 구성',
+      configureSaml: 'SAML 구성',
+      enableMfa: '내 계정에 MFA 활성화',
+    },
+    outro: {
+      title: '모두 준비되었습니다 — 문제가 생겼을 때 갈 곳',
+      system: '시스템 페이지 — 버전, 로그, 디버그 번들, 지원 내보내기.',
+      diagnostic: '연결 진단 — 프린터가 연결되지 않거나, 카메라가 검정이거나, FTP가 실패할 때. 프린터 카드 메뉴에서 엽니다.',
+      logScanner: '로그 상태 스캐너 — 알려진 해결책과 함께 반복되는 런타임 문제를 표시합니다.',
+      wiki: 'wiki.bambuddy.cool 위키 — 전체 기능 문서.',
+      discord: 'Discord — 커뮤니티 도움, 사용 관련 질문은 GitHub보다 빠릅니다.',
+      github: 'GitHub Issues — 실제 버그 보고와 기능 요청용.',
+      rehome: '이 둘러보기를 다시 보고 싶으신가요? 사이드바 아래쪽에 있습니다.',
+    },
+    helpIcon: {
+      openWiki: '이 섹션의 위키 페이지 열기',
+    },
+  },
 };

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

@@ -6222,4 +6222,196 @@ export default {
     noReadAccess: 'Você não tem permissão para visualizar previsões de inventário.',
     noWriteAccess: 'Você não tem permissão para modificar as configurações de previsão.',
   },
+
+  // Tour de integração
+  onboarding: {
+    button: {
+      next: 'Continuar',
+      back: 'Voltar',
+      skip: 'Pular',
+      skipTour: 'Pular o tour',
+      done: 'Concluído',
+      interested: 'Quero saber mais',
+      notInterested: 'Mostrar depois',
+      remindLater: 'Lembrar mais tarde',
+    },
+    welcome: {
+      title: 'Bem-vindo ao Bambuddy',
+      body: 'O Bambuddy substitui a nuvem da Bambu Lab por um painel local. Seus dados, impressões, carretéis e timelapses ficam no seu hardware. Quer fazer um tour de cinco minutos?',
+      startTour: 'Iniciar o tour',
+      experienced: 'Já sei me virar por aqui',
+    },
+    about: {
+      title: 'O que o Bambuddy é — e o que não é',
+      doesTitle: 'O que o Bambuddy faz',
+      doesBody: 'Substitui a nuvem da Bambu localmente, acompanha o AMS e o inventário de filamento por RFID, gerencia uma fila de impressão, arquiva cada impressão finalizada, expõe uma impressora virtual ao seu fatiador, suporta múltiplos usuários e traz integração de primeira com Home Assistant e Tailscale.',
+      isntTitle: 'O que o Bambuddy não é',
+      isntBody: 'Não é um fatiador (o Bambuddy delega para o BambuStudio ou o OrcaSlicer), não é um serviço de nuvem, não é uma ferramenta de firmware nem uma interface para o Klipper.',
+      privacy: 'Sem telemetria. Sem contas. bambuddy.cool serve apenas a documentação.',
+    },
+    auth: {
+      title: 'Tranque a porta da frente primeiro',
+      body: 'Se alguém mais na sua rede, no seu tailnet ou via seu proxy reverso conseguir acessar esta URL, ative a autenticação agora. Senhas, OIDC, SAML e MFA já vêm prontos.',
+      severity: 'O Bambuddy pode controlar suas impressoras, gerenciar seus arquivos e ler as câmeras — trate esta URL como um painel administrativo.',
+      enableNow: 'Ativar a autenticação agora',
+      later: 'Mais tarde — esta URL é privada',
+    },
+    addPrinter: {
+      title: 'Adicione sua primeira impressora',
+      body: 'Você precisa de três coisas: o modelo da impressora, o endereço IP dela na sua rede e o código de acesso.',
+      modelLabel: 'Modelo',
+      modelHint: 'Detectado automaticamente pela descoberta, ou escolha manualmente se o Bambuddy não enxergar a impressora.',
+      ipLabel: 'Endereço IP',
+      ipHint: 'Exibido na tela da impressora em Configurações → Rede. Uma reserva DHCP no roteador o mantém estável.',
+      codeLabel: 'Código de acesso',
+      codeHint: 'Exibido na tela da impressora; o caminho depende do modelo — veja o popover abaixo.',
+      lanModeWarning: 'Ative o modo apenas LAN na impressora (famílias X1, H2 e P2S). Sem isso, as portas MQTT e FTP ficam bloqueadas.',
+      devModeWarning: 'Ative o Modo Desenvolvedor na tela da impressora — necessário para controle por MQTT na maioria dos modelos.',
+      dockerWarning: 'Usuários de Docker bridge: a descoberta pode não encontrar a impressora. Use o caminho manual por IP.',
+      addViaDiscovery: 'Adicionar por descoberta',
+      addManually: 'Adicionar manualmente por IP',
+    },
+    verifyConnection: {
+      title: 'Vamos confirmar que o Bambuddy consegue conversar com ela',
+      mqttLabel: 'Controle MQTT (porta 8883)',
+      cameraLabel: 'Stream da câmera (RTSPS porta 322, apenas X1 / H2 / P2S)',
+      ftpLabel: 'Transferência de arquivos (FTP porta 990)',
+      allGreen: 'Os três canais alcançaram a impressora em menos de 30 segundos.',
+      issuesFound: '{{count}} problemas encontrados — abra o diagnóstico para ver os detalhes.',
+      runDiagnostic: 'Executar o diagnóstico completo',
+    },
+    tourCard: {
+      title: 'Um tour rápido pelo card da impressora',
+      status: 'Linha de status — estado da impressora, tempo restante, fase atual. Seu resumo em um piscar de olhos.',
+      ams: 'Linha do AMS — cores e tipos dos slots vêm do RFID, o resto do seu inventário; o botão de secagem também fica aqui.',
+      camera: 'Mosaico da câmera — o mesmo stream ao vivo que o BambuStudio usa, mas local. Sem passar pela nuvem.',
+      controls: 'Controles — pausar, retomar, cancelar, luzes, ventoinhas — os mesmos comandos da tela da impressora.',
+      customize: 'Clique com o botão direito no card para reorganizar os mosaicos ou esconder o que você não precisa.',
+    },
+    inventoryMode: {
+      title: 'Acompanhe seu filamento',
+      body: 'O Bambuddy pode cuidar dos seus carretéis. Escolha um modo agora — trocar depois faz perder o histórico.',
+      internalTitle: 'Interno (recomendado)',
+      internalBody: 'Inventário embutido, espelha o AMS, lê RFID e desconta o peso automaticamente conforme você imprime. Melhor para a maioria.',
+      spoolmanTitle: 'Spoolman',
+      spoolmanBody: 'Aponte para uma instância existente do Spoolman e o Bambuddy sincroniza a partir dela. Ideal se você já usa Spoolman.',
+      noneTitle: 'Desligado',
+      noneBody: 'Pular completamente o rastreamento de filamento. Você pode ativar depois, mas impressões antigas não serão preenchidas retroativamente.',
+      footgun: 'Trocar de modo mais tarde não migra os dados que você já tiver cadastrado.',
+    },
+    addSpool: {
+      title: 'Cadastre seu primeiro carretel',
+      intro: 'Escolha o método que preferir:',
+      rfidTitle: 'Leitura RFID (carretéis Bambu)',
+      rfidBody: 'Coloque o carretel no AMS — o Bambuddy lê a tag RFID sozinho. Sem cadastro manual.',
+      spoolbuddyTitle: 'SpoolBuddy',
+      spoolbuddyBody: 'Se você tem uma caixa SpoolBuddy, grave uma tag RFID regravável para carretéis que não sejam Bambu.',
+      manualTitle: 'Digitar à mão',
+      manualBody: 'Marca, material, cor, peso.',
+      catalog: 'O catálogo de cores embutido cobre as grandes marcas — os nomes se autocompletam enquanto você digita.',
+      addManually: 'Adicionar à mão',
+      useRfid: 'Usar RFID',
+    },
+    spoolmanSync: {
+      title: 'Diga ao Bambuddy onde fica o Spoolman',
+      body: 'O Bambuddy importa sua biblioteca de carretéis e a mantém sincronizada. As leituras RFID continuam funcionando — elas criam carretéis novos dentro do Spoolman.',
+    },
+    bambuCloud: {
+      title: 'Sincronize seus perfis de filamento e impressão da Bambu Lab',
+      body: 'Se você guarda perfis personalizados de filamento ou impressão no BambuStudio e na nuvem da Bambu, o Bambuddy pode trazê-los para que a interface web, a fila e a impressora virtual usem a mesma biblioteca.',
+      storage: 'As credenciais ficam armazenadas criptografadas em repouso e só são enviadas para a API oficial da Bambu. O código-fonte é aberto.',
+      signIn: 'Entrar na Bambu',
+      useDefaults: 'Pular — usar os padrões embutidos',
+    },
+    sidebar: {
+      title: 'O resto do Bambuddy num piscar de olhos',
+      queue: 'Fila de Impressão — trabalhos por arrastar e soltar, despacho automático para impressoras livres, secagem automática para PETG e PA.',
+      archives: 'Arquivos — cada impressão finalizada, com timelapse, foto final, gcode e 3MF. Reimprima direto de qualquer linha.',
+      stats: 'Estatísticas — horas, filamento por marca, material e cor, custo de energia (defina o preço da luz uma vez), taxa de sucesso.',
+      maintenance: 'Manutenção — desgaste do bico, tensão das correias, troca do hotend, intervalos de lubrificação. Tarefas padrão mais as suas.',
+      files: 'Arquivos — sua biblioteca de 3MF, gcode e STL. Subir, etiquetar, buscar, enviar para qualquer impressora. Raízes externas montam compartilhamentos NAS.',
+      projects: 'Projetos — agrupe arquivos em um projeto lógico e acompanhe quais placas já foram impressas.',
+      helpIcon: 'Cada página tem um ícone de interrogação no canto superior direito que abre a página correspondente da wiki dentro do contexto.',
+    },
+    vp: {
+      title: 'Impressora Virtual — deixe seu fatiador enviar direto para o Bambuddy',
+      body: 'BambuStudio ou OrcaSlicer podem enviar impressões para o Bambuddy em vez da nuvem da Bambu. Escolha o modo que combina com sua configuração:',
+      bridgeTitle: 'Modo ponte',
+      bridgeBody: 'Substituto direto da nuvem. O fatiador envia para o Bambuddy, que repassa para a impressora real.',
+      queueTitle: 'Modo fila',
+      queueBody: 'O fatiador envia para um coletor virtual e o Bambuddy enfileira o trabalho para despacho.',
+      proxyTitle: 'Modo proxy',
+      proxyBody: 'O fatiador conversa com o Bambuddy, que faz passagem completa com reescrita de MQTT, FTP e RTSP. Ideal para configurações com vários fatiadores.',
+      archiveTitle: 'Arquivo / Revisão',
+      archiveBody: 'O fatiador envia, o Bambuddy guarda mas não imprime. Útil para fluxos de auditoria e aprovação.',
+      ipNote: 'A VP reserva um IP livre na sua interface de bind para parecer uma impressora real ao fatiador.',
+      dockerWarning: 'No modo Docker bridge é preciso expor portas explicitamente — veja a página da wiki sobre divisão de portas passivas de FTP.',
+      setUp: 'Configurar uma Impressora Virtual',
+    },
+    slicerApi: {
+      title: 'Fatie a partir de URLs do MakerWorld ou da sua biblioteca — sem abrir o BambuStudio',
+      body: 'Requer o contêiner sidecar orca-slicer-api (docker-compose separado, link abaixo). O Bambuddy conversa com ele por HTTP.',
+      status: 'Status: ainda amadurecendo no upstream — sólido hoje para trabalhos de um filamento e uma placa; o segfault com 3MF multi-filamento está sendo corrigido upstream.',
+      configure: 'Configurar o sidecar',
+    },
+    externalRoots: {
+      title: 'Monte um compartilhamento NAS, um SSD externo ou uma unidade de projeto',
+      body: 'Defina BAMBUDDY_EXTERNAL_ROOTS no docker-compose.yml e monte o caminho do host. O Bambuddy mostra a pasta automaticamente no Gerenciador de Arquivos.',
+      readOnlyWarning: 'Monte como somente leitura, a menos que você realmente queira que os usuários gravem de volta no compartilhamento.',
+    },
+    makerworld: {
+      title: 'Cole qualquer URL do MakerWorld — o Bambuddy baixa o 3MF para você',
+      body: 'A busca dentro do app ficou de fora desta versão, então cole a URL direto do site do MakerWorld. As importações respeitam a sua estrutura de pastas externas.',
+      tryNow: 'Experimentar agora',
+    },
+    obico: {
+      title: 'Detecção de falhas por ML auto-hospedada — sem conta na nuvem do Obico',
+      body: 'O Bambuddy fala direto com seu servidor Obico ML auto-hospedado. Ativável por impressora, desligado por padrão.',
+      smoothing: 'O ajuste de suavização e zona morta fica no painel Obico de cada impressora.',
+    },
+    integrations: {
+      title: 'Home Assistant e webhooks',
+      body: 'Integração de primeira com o Home Assistant: sensores por impressora (estado, temperatura, tempo restante, slots do AMS) e serviços para iniciar, pausar ou cancelar. Os webhooks disparam em eventos de impressão, de fila e de arquivo.',
+      secret: 'O segredo de assinatura dos webhooks fica em Configurações → Integrações.',
+    },
+    tailscale: {
+      title: 'Acesse o Bambuddy de qualquer lugar pelo seu tailnet',
+      body: 'HTTPS com MagicDNS via Let us Encrypt — o Bambuddy pede certificados por tailscale cert e os serve sozinho.',
+      openSettings: 'Abrir as configurações do Tailscale',
+    },
+    notifications: {
+      title: 'Ser avisado quando impressões terminam, falham ou pedem atenção',
+      body: 'Canais: no app, push do navegador, Discord, Telegram, Pushover, Gotify, ntfy, e-mail e webhook. Filtros por evento direcionam alertas de umidade do AMS ao Discord, fotos finais ao Telegram e por aí vai.',
+      configure: 'Configurar agora',
+    },
+    users: {
+      title: 'Adicione contas para o resto da sua equipe',
+      body: 'Cada usuário tem suas próprias permissões, histórico de impressões e configurações de notificação. O registro mostra quem disparou cada trabalho.',
+      addUser: 'Adicionar um usuário',
+    },
+    groups: {
+      title: 'Agrupe usuários por papel',
+      body: 'O Bambuddy vem com grupos padrão: Administradores, Operadores e Visualizadores. Crie seus próprios grupos para papéis sob medida — uma conta infantil somente leitura, uma conta de parceiro com acesso total e assim por diante.',
+    },
+    sso: {
+      title: 'Single sign-on e MFA',
+      body: 'OIDC (Authentik, Authelia, Keycloak, Google, GitHub) e SAML 2.0 para SSO corporativo. MFA por usuário via TOTP. A chave de criptografia é gerada na primeira inicialização; sobrescreva via variável de ambiente para fluxos com gerenciador de segredos.',
+      configureOidc: 'Configurar OIDC',
+      configureSaml: 'Configurar SAML',
+      enableMfa: 'Ativar MFA na minha conta',
+    },
+    outro: {
+      title: 'Está tudo pronto — aqui é onde procurar quando algo sair do trilho',
+      system: 'Página de Sistema — versão, logs, pacote de depuração, exportação de suporte.',
+      diagnostic: 'Diagnóstico de Conexão — impressora não conecta, câmera preta, FTP falhando. Abra pelo menu do card da impressora.',
+      logScanner: 'Scanner de Saúde de Logs — sinaliza problemas recorrentes em tempo de execução com sugestões de solução conhecidas.',
+      wiki: 'Wiki em wiki.bambuddy.cool — documentação completa de funcionalidades.',
+      discord: 'Discord — ajuda da comunidade, mais rápido que o GitHub para dúvidas de uso.',
+      github: 'GitHub Issues — para bugs reais e pedidos de novas funcionalidades.',
+      rehome: 'Precisa rever este tour? Ele mora no rodapé da barra lateral.',
+    },
+    helpIcon: {
+      openWiki: 'Abrir a página wiki desta seção',
+    },
+  },
 };

+ 192 - 0
frontend/src/i18n/locales/tr.ts

@@ -6162,4 +6162,196 @@ export default {
     noReadAccess: 'Envanter tahminlerini görüntüleme izniniz yok.',
     noWriteAccess: 'Tahmin ayarlarını değiştirme izniniz yok.',
   },
+
+  // Onboarding turu
+  onboarding: {
+    button: {
+      next: 'Devam et',
+      back: 'Geri',
+      skip: 'Atla',
+      skipTour: 'Turu atla',
+      done: 'Tamam',
+      interested: 'Daha fazla bilgi',
+      notInterested: 'Daha sonra göster',
+      remindLater: 'Sonra hatırlat',
+    },
+    welcome: {
+      title: 'Bambuddy uygulamasına hoş geldiniz',
+      body: 'Bambuddy, Bambu Lab bulutunu yerel öncelikli bir panoyla değiştirir. Verileriniz, baskılarınız, makaralarınız ve hızlandırılmış videolarınız kendi donanımınızda kalır. Beş dakikalık bir tur ister misiniz?',
+      startTour: 'Turu başlat',
+      experienced: 'Yolumu zaten biliyorum',
+    },
+    about: {
+      title: 'Bambuddy nedir — ve ne değildir',
+      doesTitle: 'Bambuddy ne yapar',
+      doesBody: 'Bambu bulutunu yerel olarak değiştirir, AMS ile filament envanterini RFID üzerinden izler, bir baskı kuyruğu çalıştırır, biten her baskıyı arşivler, dilimleyicinize sanal bir yazıcı sunar, birden çok kullanıcıyı destekler ve birinci sınıf Home Assistant ile Tailscale entegrasyonu sağlar.',
+      isntTitle: 'Bambuddy ne değildir',
+      isntBody: 'Bir dilimleyici değildir (Bambuddy işi BambuStudio veya OrcaSlicer’a devreder), bir bulut hizmeti değildir, bir donanım yazılımı aracı değildir, bir Klipper arayüzü değildir.',
+      privacy: 'Telemetri yok. Hesap yok. bambuddy.cool yalnızca dokümantasyonu sunar.',
+    },
+    auth: {
+      title: 'Önce ön kapıyı kilitleyin',
+      body: 'Ağınızdaki, tailnet’inizdeki veya ters proxy’nizdeki başka biri bu adrese ulaşabiliyorsa, kimlik doğrulamayı şimdi açın. Parolalar, OIDC, SAML ve MFA hepsi yerleşik olarak gelir.',
+      severity: 'Bambuddy yazıcılarınızı kontrol edebilir, dosyalarınızı yönetebilir ve kamera akışlarınızı okuyabilir — bu adresi bir yönetici paneli gibi düşünün.',
+      enableNow: 'Kimlik doğrulamayı şimdi etkinleştir',
+      later: 'Daha sonra — bu adres özel',
+    },
+    addPrinter: {
+      title: 'İlk yazıcınızı ekleyin',
+      body: 'Üç şeye ihtiyacınız var: yazıcı modeli, ağdaki IP adresi ve erişim kodu.',
+      modelLabel: 'Model',
+      modelHint: 'Keşif ile otomatik bulunur ya da Bambuddy yazıcıyı göremiyorsa elle seçin.',
+      ipLabel: 'IP adresi',
+      ipHint: 'Yazıcı ekranında Ayarlar → Ağ altında görünür. Yönlendiricinizdeki bir DHCP rezervasyonu adresi sabit tutar.',
+      codeLabel: 'Erişim kodu',
+      codeHint: 'Yazıcı ekranında gösterilir; yol modele göre değişir — aşağıdaki açılır pencereye bakın.',
+      lanModeWarning: 'Yazıcıda LAN-Only modunu açın (X1, H2 ve P2S ailesi). Bu olmadan MQTT ve FTP portları kapalı kalır.',
+      devModeWarning: 'Yazıcı ekranında Geliştirici Modunu açın — çoğu modelde MQTT denetimi için gereklidir.',
+      dockerWarning: 'Docker bridge kullanıcıları: keşif yazıcıyı bulamayabilir. Bunun yerine elle IP ile ekleme yolunu kullanın.',
+      addViaDiscovery: 'Keşif ile ekle',
+      addManually: 'IP ile elle ekle',
+    },
+    verifyConnection: {
+      title: 'Bambuddy’nin yazıcıyla konuşabildiğinden emin olalım',
+      mqttLabel: 'MQTT denetimi (port 8883)',
+      cameraLabel: 'Kamera akışı (RTSPS port 322, yalnızca X1 / H2 / P2S)',
+      ftpLabel: 'Dosya aktarımı (FTP port 990)',
+      allGreen: 'Üç kanalın üçü de yazıcıya 30 saniye içinde ulaştı.',
+      issuesFound: '{{count}} sorun bulundu — ayrıntılar için tanılamayı açın.',
+      runDiagnostic: 'Tam tanılamayı çalıştır',
+    },
+    tourCard: {
+      title: 'Yazıcı kartında kısa bir tur',
+      status: 'Durum satırı — yazıcı durumu, tahmini süre, mevcut aşama. Bir bakışta özetiniz.',
+      ams: 'AMS satırı — slot renkleri ve türleri RFID’den, gerisi envanterinizden gelir; kurutma düğmesi de burada durur.',
+      camera: 'Kamera kutusu — BambuStudio’nun kullandığı canlı akışın aynısı, ama yerel. Buluta gidip gelmek yok.',
+      controls: 'Denetimler — duraklat, sürdür, iptal, ışıklar, fanlar — yazıcı ekranındaki ile aynı denetimler.',
+      customize: 'Kartı sağ tıklayarak kutuları yeniden düzenleyebilir veya ihtiyacınız olmayanları gizleyebilirsiniz.',
+    },
+    inventoryMode: {
+      title: 'Filamentinizi takip edin',
+      body: 'Bambuddy makaralarınızı izleyebilir. Şimdi bir mod seçin — daha sonra geçiş yapmak geçmiş verileri kaybettirir.',
+      internalTitle: 'Dahili (önerilen)',
+      internalBody: 'Yerleşik envanter, AMS’i yansıtır, RFID okur, baskı sırasında ağırlığı otomatik düşer. Çoğu kullanıcı için en iyisi.',
+      spoolmanTitle: 'Spoolman',
+      spoolmanBody: 'Mevcut bir Spoolman örneğini gösterin, Bambuddy oradan eşitler. Zaten Spoolman çalıştırıyorsanız en iyisi.',
+      noneTitle: 'Kapalı',
+      noneBody: 'Filament takibini tamamen atlayın. Daha sonra açabilirsiniz, ancak geçmiş baskılar geriye dönük doldurulmaz.',
+      footgun: 'Daha sonra mod değiştirmek, daha önce girdiğiniz verileri taşımaz.',
+    },
+    addSpool: {
+      title: 'İlk makaranızı ekleyin',
+      intro: 'Size uygun olan yöntemi seçin:',
+      rfidTitle: 'RFID taraması (Bambu makaraları)',
+      rfidBody: 'Makarayı AMS’e takın — Bambuddy RFID etiketini otomatik okur. Elle giriş gerekmez.',
+      spoolbuddyTitle: 'SpoolBuddy',
+      spoolbuddyBody: 'SpoolBuddy kutunuz varsa, Bambu dışı makaralar için yazılabilir bir RFID etiketi tarayın.',
+      manualTitle: 'Elle girin',
+      manualBody: 'Marka, malzeme, renk, ağırlık.',
+      catalog: 'Yerleşik renk kataloğu büyük markaları kapsar — yazarken adlar otomatik tamamlanır.',
+      addManually: 'Elle ekle',
+      useRfid: 'RFID kullan',
+    },
+    spoolmanSync: {
+      title: 'Spoolman’in nerede çalıştığını Bambuddy’ye söyleyin',
+      body: 'Bambuddy mevcut makara kitaplığınızı çeker ve eşitlemeyi sürdürür. RFID taramaları çalışmaya devam eder — yeni makaraları doğrudan Spoolman içinde oluşturur.',
+    },
+    bambuCloud: {
+      title: 'Filament ve baskı profillerinizi Bambu Lab’dan eşitleyin',
+      body: 'BambuStudio ve Bambu bulutunda özel filament veya baskı profilleri tutuyorsanız, Bambuddy bunları içeri alabilir; böylece web arayüzü, kuyruk ve sanal yazıcı aynı kitaplığı görür.',
+      storage: 'Kimlik bilgileri şifrelenmiş olarak saklanır ve yalnızca resmi Bambu API’sine gönderilir. Kaynak koda açıktır.',
+      signIn: 'Bambu hesabıyla oturum aç',
+      useDefaults: 'Atla — yerleşik varsayılanları kullan',
+    },
+    sidebar: {
+      title: 'Bambuddy’nin geri kalanına bir bakış',
+      queue: 'Baskı kuyruğu — sürükle bırak işler, boş yazıcılara otomatik gönderim, PETG ve PA için otomatik kurutma.',
+      archives: 'Arşivler — biten her baskı, hızlandırılmış video, bitiş fotoğrafı, gcode ve 3MF ile birlikte. Herhangi bir satırdan doğrudan yeniden bastırın.',
+      stats: 'İstatistikler — saatler, marka ile malzeme ve renge göre filament, enerji maliyeti (elektrik fiyatını bir kez girin), başarı oranı.',
+      maintenance: 'Bakım — nozul aşınması, kayış gerginliği, hotend değişimi, yağlama aralıkları. Yerleşik varsayılanlar ile özel görevler.',
+      files: 'Dosyalar — 3MF, gcode ve STL kitaplığınız. Yükleyin, etiketleyin, arayın, herhangi bir yazıcıya gönderin. Harici kökler NAS paylaşımlarını bağlar.',
+      projects: 'Projeler — dosyaları mantıksal bir projede gruplayın ve hangi plakaların basıldığını izleyin.',
+      helpIcon: 'Her sayfanın sağ üst köşesinde, eşleşen wiki sayfasını bağlam içinde açan bir soru işareti simgesi vardır.',
+    },
+    vp: {
+      title: 'Sanal Yazıcı — dilimleyiciniz doğrudan Bambuddy’ye göndersin',
+      body: 'BambuStudio veya OrcaSlicer, baskıları Bambu bulutu yerine Bambuddy’ye gönderebilir. Kurulumunuza uyan modu seçin:',
+      bridgeTitle: 'Köprü modu',
+      bridgeBody: 'Doğrudan bulut yerine geçer. Dilimleyici Bambuddy’ye gönderir, Bambuddy gerçek yazıcıya iletir.',
+      queueTitle: 'Kuyruk modu',
+      queueBody: 'Dilimleyici sanal bir toplayıcıya gönderir ve Bambuddy işi gönderim için kuyruğa alır.',
+      proxyTitle: 'Vekil modu',
+      proxyBody: 'Dilimleyici Bambuddy ile konuşur, Bambuddy tam MQTT, FTP ve RTSP yeniden yazımıyla geçişi sağlar. Çoklu dilimleyici kurulumları için en iyisi.',
+      archiveTitle: 'Arşiv / Gözden geçirme',
+      archiveBody: 'Dilimleyici gönderir, Bambuddy saklar ama basmaz. Denetim ve onay akışları için kullanışlıdır.',
+      ipNote: 'VP, bağlama arayüzünüzde boş bir IP üstlenir, böylece dilimleyiciye gerçek bir yazıcı gibi görünür.',
+      dockerWarning: 'Docker bridge modu açık port tanımı gerektirir — FTP pasif port bölümleme için Docker wiki sayfasına bakın.',
+      setUp: 'Sanal Yazıcı kur',
+    },
+    slicerApi: {
+      title: 'BambuStudio’yu açmadan MakerWorld adreslerinden veya kitaplığınızdan dilimleyin',
+      body: 'orca-slicer-api yan konteyneri gerektirir (ayrı docker-compose, bağlantı aşağıda). Bambuddy onunla HTTP üzerinden konuşur.',
+      status: 'Durum: yukarı akımda hâlâ olgunlaşıyor — tek filament ve tek plaka işleri için bugün güvenilir, çoklu filament 3MF segfault sorunu yukarı akımda düzeltiliyor.',
+      configure: 'Yan konteyneri yapılandır',
+    },
+    externalRoots: {
+      title: 'NAS paylaşımını, harici SSD’yi veya proje sürücüsünü bağlayın',
+      body: 'docker-compose.yml dosyasında BAMBUDDY_EXTERNAL_ROOTS değişkenini ayarlayın ve ana makine yolunu bağlayın. Bambuddy klasörü Dosya Yöneticisi’nde otomatik gösterir.',
+      readOnlyWarning: 'Kullanıcıların paylaşıma geri yazmasını özellikle istemiyorsanız salt okunur olarak bağlayın.',
+    },
+    makerworld: {
+      title: 'Herhangi bir MakerWorld adresini yapıştırın — Bambuddy 3MF dosyasını sizin için indirir',
+      body: 'Bu sürümde uygulama içi arama çıkarıldı, bu yüzden adresi MakerWorld sitesinden yapıştırın. İçe aktarımlar harici klasör düzeninize uyar.',
+      tryNow: 'Hemen dene',
+    },
+    obico: {
+      title: 'Kendi sunucunuzda ML baskı hatası algılama — Obico bulut hesabı gerekmez',
+      body: 'Bambuddy doğrudan kendi sunucunuzdaki Obico ML sunucusuyla konuşur. Yazıcı başına etkinleştirilir, varsayılan olarak kapalıdır.',
+      smoothing: 'Yumuşatma ve ölü bölge ayarı yazıcıya özel Obico panelinde yapılır.',
+    },
+    integrations: {
+      title: 'Home Assistant ve webhook’lar',
+      body: 'Birinci sınıf Home Assistant entegrasyonu: her yazıcı için sensörler (durum, sıcaklık, tahmini süre, AMS slotları) ile başlatma, duraklatma veya iptal etme servisleri. Webhook’lar baskı, kuyruk ve arşiv olaylarında tetiklenir.',
+      secret: 'Webhook imzalama gizli anahtarı Ayarlar → Entegrasyonlar altında bulunur.',
+    },
+    tailscale: {
+      title: 'Bambuddy’ye tailnet’iniz üzerinden her yerden erişin',
+      body: 'Let’s Encrypt ile MagicDNS HTTPS — Bambuddy sertifikaları tailscale cert üzerinden ister ve kendisi sunar.',
+      openSettings: 'Tailscale ayarlarını aç',
+    },
+    notifications: {
+      title: 'Baskılar bittiğinde, başarısız olduğunda veya ilgilenmeniz gerektiğinde bildirim alın',
+      body: 'Kanallar: uygulama içi, tarayıcı push, Discord, Telegram, Pushover, Gotify, ntfy, e-posta ve webhook. Olay başına filtreler AMS nem uyarılarını Discord’a, bitiş fotoğraflarını Telegram’a vb. yönlendirir.',
+      configure: 'Şimdi yapılandır',
+    },
+    users: {
+      title: 'Ekibinizin geri kalanı için hesap ekleyin',
+      body: 'Her kullanıcının kendi izinleri, baskı geçmişi ve bildirim ayarları olur. Baskı günlüğü hangi işi kimin başlattığını gösterir.',
+      addUser: 'Kullanıcı ekle',
+    },
+    groups: {
+      title: 'Kullanıcıları role göre gruplayın',
+      body: 'Bambuddy varsayılan gruplarla gelir: Yöneticiler, Operatörler, İzleyiciler. Özel roller için kendi gruplarınızı oluşturun — yalnızca okuma yetkili bir çocuk hesabı, tam erişimli bir ortak vb.',
+    },
+    sso: {
+      title: 'Tek oturum açma ve MFA',
+      body: 'Kuruluş SSO’su için OIDC (Authentik, Authelia, Keycloak, Google, GitHub) ve SAML 2.0. Kullanıcı başına MFA, TOTP üzerinden. Şifreleme anahtarı ilk başlatmada kendiliğinden üretilir; sır yöneticisi akışları için ortam değişkeniyle geçersiz kılınabilir.',
+      configureOidc: 'OIDC yapılandır',
+      configureSaml: 'SAML yapılandır',
+      enableMfa: 'Hesabımda MFA’yı etkinleştir',
+    },
+    outro: {
+      title: 'Her şey hazır — bir şeyler ters giderse nereye gideceğinizi anlatalım',
+      system: 'Sistem sayfası — sürüm, günlükler, hata ayıklama paketi, destek dışa aktarımı.',
+      diagnostic: 'Bağlantı Tanılama — yazıcı bağlanmıyor, kamera siyah, FTP başarısız. Yazıcı kartı menüsünden açılır.',
+      logScanner: 'Günlük Sağlık Tarayıcı — yinelenen çalışma zamanı sorunlarını bilinen çözüm önerileriyle işaretler.',
+      wiki: 'wiki.bambuddy.cool adresindeki wiki — tam özellik dokümantasyonu.',
+      discord: 'Discord — topluluk yardımı, kullanım soruları için GitHub’dan daha hızlıdır.',
+      github: 'GitHub Issues — gerçek hatalar ve özellik istekleri için.',
+      rehome: 'Bu turu yeniden görmek mi? Kenar çubuğunun en altında bulunur.',
+    },
+    helpIcon: {
+      openWiki: 'Bu bölümün wiki sayfasını aç',
+    },
+  },
 };

+ 192 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -6221,4 +6221,196 @@ export default {
     noReadAccess: '您没有查看库存预测的权限。',
     noWriteAccess: '您没有修改预测设置的权限。',
   },
+
+  // 新手引导
+  onboarding: {
+    button: {
+      next: '继续',
+      back: '返回',
+      skip: '跳过',
+      skipTour: '跳过引导',
+      done: '完成',
+      interested: '了解更多',
+      notInterested: '稍后再看',
+      remindLater: '稍后提醒我',
+    },
+    welcome: {
+      title: '欢迎使用 Bambuddy',
+      body: 'Bambuddy 用本地优先的仪表板取代 Bambu Lab 云。您的数据、打印记录、线轴和延时摄影都留在您自己的硬件上。要不要花五分钟看一遍引导?',
+      startTour: '开始引导',
+      experienced: '我已经熟悉了',
+    },
+    about: {
+      title: 'Bambuddy 是什么 — 又不是什么',
+      doesTitle: 'Bambuddy 能做什么',
+      doesBody: '在本地替代 Bambu 云,使用 RFID 跟踪 AMS 和线材库存,运行打印队列,归档每一次完成的打印,向您的切片软件提供虚拟打印机,支持多用户,并自带一流的 Home Assistant 与 Tailscale 集成。',
+      isntTitle: 'Bambuddy 不是什么',
+      isntBody: '不是切片软件(Bambuddy 会把任务交给 BambuStudio 或 OrcaSlicer),不是云服务,不是固件工具,也不是 Klipper 界面。',
+      privacy: '没有遥测。没有账号。bambuddy.cool 只提供文档。',
+    },
+    auth: {
+      title: '先锁好大门',
+      body: '如果您网络上的其他人、您的 tailnet 或您的反向代理能访问到这个网址,请立刻打开身份验证。密码、OIDC、SAML 和 MFA 都已内置。',
+      severity: 'Bambuddy 可以控制您的打印机、管理您的文件、读取您的摄像头画面 — 请把这个网址当作管理面板对待。',
+      enableNow: '立即启用身份验证',
+      later: '稍后 — 这个网址是私有的',
+    },
+    addPrinter: {
+      title: '添加第一台打印机',
+      body: '您需要三样东西:打印机型号、它在网络中的 IP 地址以及它的访问码。',
+      modelLabel: '型号',
+      modelHint: '通过发现自动识别,如果 Bambuddy 看不到打印机,也可以手动选择。',
+      ipLabel: 'IP 地址',
+      ipHint: '可在打印机屏幕的“设置 → 网络”中查看。在路由器中做 DHCP 保留可以让它保持稳定。',
+      codeLabel: '访问码',
+      codeHint: '可在打印机屏幕上查看;具体路径因型号而异 — 请参考下方弹窗。',
+      lanModeWarning: '请在打印机上启用仅局域网模式(X1、H2 和 P2S 系列)。否则 MQTT 和 FTP 端口仍会被屏蔽。',
+      devModeWarning: '请在打印机屏幕上启用开发者模式 — 大多数机型的 MQTT 控制都需要它。',
+      dockerWarning: 'Docker bridge 网络用户:发现可能找不到打印机。请改用按 IP 手动添加的方式。',
+      addViaDiscovery: '通过发现添加',
+      addManually: '按 IP 手动添加',
+    },
+    verifyConnection: {
+      title: '让我们确认 Bambuddy 能与打印机通信',
+      mqttLabel: 'MQTT 控制(端口 8883)',
+      cameraLabel: '摄像头串流(RTSPS 端口 322,仅 X1 / H2 / P2S)',
+      ftpLabel: '文件传输(FTP 端口 990)',
+      allGreen: '三条通道都在 30 秒内连上了打印机。',
+      issuesFound: '发现了 {{count}} 个问题 — 打开诊断查看详情。',
+      runDiagnostic: '运行完整诊断',
+    },
+    tourCard: {
+      title: '快速了解打印机卡片',
+      status: '状态行 — 打印机状态、预计剩余时间、当前阶段。一眼看清整体情况。',
+      ams: 'AMS 行 — 槽位的颜色和类型来自 RFID,其余信息来自您的库存;干燥按钮也在这里。',
+      camera: '摄像头磁贴 — 与 BambuStudio 使用的是同一个实时串流,但走的是本地。无需绕道云端。',
+      controls: '控制 — 暂停、恢复、取消、灯光、风扇 — 与打印机屏幕上的操作相同。',
+      customize: '右键点击卡片可以重新排列磁贴或隐藏您用不到的内容。',
+    },
+    inventoryMode: {
+      title: '管理您的线材',
+      body: 'Bambuddy 可以帮您盯着线轴。现在选择一个模式 — 之后切换会丢失历史数据。',
+      internalTitle: '内置(推荐)',
+      internalBody: '内置库存,会镜像 AMS,读取 RFID,打印时自动扣减重量。适合大多数用户。',
+      spoolmanTitle: 'Spoolman',
+      spoolmanBody: '指向一台现有的 Spoolman 实例,Bambuddy 会从中同步。如果您已经在用 Spoolman,选这个最合适。',
+      noneTitle: '关闭',
+      noneBody: '完全跳过线材跟踪。您可以稍后再打开,但过去的打印不会被回填。',
+      footgun: '之后切换模式不会迁移您已经录入的数据。',
+    },
+    addSpool: {
+      title: '添加第一个线轴',
+      intro: '选择适合您的方式:',
+      rfidTitle: 'RFID 扫描(Bambu 线轴)',
+      rfidBody: '把线轴装入 AMS — Bambuddy 会自动读取 RFID 标签。无需手动录入。',
+      spoolbuddyTitle: 'SpoolBuddy',
+      spoolbuddyBody: '如果您有 SpoolBuddy 设备,可以为非 Bambu 线轴扫描可写入的 RFID 标签。',
+      manualTitle: '手动录入',
+      manualBody: '品牌、材料、颜色、重量。',
+      catalog: '内置的颜色目录覆盖了主流品牌 — 输入时名称会自动补全。',
+      addManually: '手动添加',
+      useRfid: '使用 RFID',
+    },
+    spoolmanSync: {
+      title: '告诉 Bambuddy 您的 Spoolman 在哪',
+      body: 'Bambuddy 会拉取您现有的线轴库并保持同步。RFID 扫描仍然可用 — 它们会直接在 Spoolman 中创建新线轴。',
+    },
+    bambuCloud: {
+      title: '从 Bambu Lab 同步您的线材与打印配置',
+      body: '如果您在 BambuStudio 和 Bambu 云中维护了自定义线材或打印配置,Bambuddy 可以把它们拉进来,让 Web 界面、队列和虚拟打印机看到同一份配置库。',
+      storage: '凭据会加密保存,并且只会发送到官方的 Bambu API。源代码是公开的。',
+      signIn: '登录 Bambu',
+      useDefaults: '跳过 — 使用内置默认值',
+    },
+    sidebar: {
+      title: '一览 Bambuddy 的其他部分',
+      queue: '打印队列 — 拖拽排序,自动派发到空闲打印机,PETG 和 PA 的自动干燥。',
+      archives: '归档 — 每一次完成的打印,附带延时摄影、收尾照片、gcode 和 3MF。可直接从任意一行重新打印。',
+      stats: '统计 — 工时、按品牌/材料/颜色统计的线材用量、电费成本(电价只需设置一次)、成功率。',
+      maintenance: '维护 — 喷嘴磨损、皮带张力、热端更换、润滑周期。内置默认任务加上自定义任务。',
+      files: '文件 — 您的 3MF、gcode 和 STL 库。上传、打标签、搜索、发送到任意打印机。外部根目录可挂载 NAS 共享。',
+      projects: '项目 — 把文件归入一个逻辑项目,并跟踪哪些打印板已经打过。',
+      helpIcon: '每个页面右上角都有一个问号图标,可在当前上下文中打开对应的 wiki 页面。',
+    },
+    vp: {
+      title: '虚拟打印机 — 让切片软件直接发送到 Bambuddy',
+      body: 'BambuStudio 或 OrcaSlicer 可以把打印任务发送给 Bambuddy 而不是 Bambu 云。请选择适合您环境的模式:',
+      bridgeTitle: '桥接模式',
+      bridgeBody: '即插即用的云替代品。切片软件发送到 Bambuddy,Bambuddy 再转发给真实的打印机。',
+      queueTitle: '队列模式',
+      queueBody: '切片软件发送到一个虚拟收集器,Bambuddy 会把任务加入队列等待派发。',
+      proxyTitle: '代理模式',
+      proxyBody: '切片软件与 Bambuddy 对话,由 Bambuddy 在透传过程中完整改写 MQTT、FTP 和 RTSP。最适合多切片软件的环境。',
+      archiveTitle: '归档 / 审阅',
+      archiveBody: '切片软件发送,Bambuddy 仅保存,不打印。适用于审计和审批流程。',
+      ipNote: '虚拟打印机会在您的绑定网卡上占用一个空闲 IP,这样在切片软件看来它就像是一台真打印机。',
+      dockerWarning: 'Docker bridge 模式需要显式暴露端口 — 关于 FTP 被动端口切分,请参阅 Docker wiki 页面。',
+      setUp: '设置虚拟打印机',
+    },
+    slicerApi: {
+      title: '从 MakerWorld URL 或您的资源库切片 — 无需打开 BambuStudio',
+      body: '需要 orca-slicer-api 边车容器(独立的 docker-compose,链接见下方)。Bambuddy 通过 HTTP 与它通信。',
+      status: '状态:上游仍在成熟中 — 目前对单线材、单板任务已可靠工作,多线材 3MF 的段错误正在上游修复中。',
+      configure: '配置边车',
+    },
+    externalRoots: {
+      title: '挂载 NAS 共享、外接 SSD 或项目盘',
+      body: '在 docker-compose.yml 中设置 BAMBUDDY_EXTERNAL_ROOTS 并绑定挂载宿主机路径。Bambuddy 会自动在文件管理器中显示该文件夹。',
+      readOnlyWarning: '除非您明确希望用户向共享回写,否则请以只读方式挂载。',
+    },
+    makerworld: {
+      title: '粘贴任意 MakerWorld URL — Bambuddy 会替您下载 3MF',
+      body: '本版本暂时下线了应用内搜索,请从 MakerWorld 网站复制 URL 粘贴过来。导入会遵循您的外部文件夹结构。',
+      tryNow: '马上试试',
+    },
+    obico: {
+      title: '自托管的 ML 打印失败检测 — 无需 Obico 云账号',
+      body: 'Bambuddy 直接与您自托管的 Obico ML 服务器通信。可按打印机单独启用,默认关闭。',
+      smoothing: '平滑参数和静默区调节位于具体打印机的 Obico 面板上。',
+    },
+    integrations: {
+      title: 'Home Assistant 与 webhook',
+      body: '一流的 Home Assistant 集成:为每台打印机提供传感器(状态、温度、预计剩余时间、AMS 槽位),以及启动、暂停、取消等服务。Webhook 会在打印事件、队列事件和归档事件时触发。',
+      secret: 'Webhook 的签名密钥位于“设置 → 集成”。',
+    },
+    tailscale: {
+      title: '通过 tailnet 从任何地方访问 Bambuddy',
+      body: '使用 Let us Encrypt 的 MagicDNS HTTPS — Bambuddy 通过 tailscale cert 申请证书,并自行提供服务。',
+      openSettings: '打开 Tailscale 设置',
+    },
+    notifications: {
+      title: '在打印完成、失败或需要关注时得到通知',
+      body: '渠道:应用内、浏览器推送、Discord、Telegram、Pushover、Gotify、ntfy、电子邮件和 webhook。按事件过滤可以把 AMS 湿度警告发到 Discord,把收尾照片发到 Telegram,等等。',
+      configure: '立即配置',
+    },
+    users: {
+      title: '为团队其他成员创建账号',
+      body: '每位用户都有自己的权限、打印历史和通知设置。打印日志会显示是谁启动了哪一个任务。',
+      addUser: '添加用户',
+    },
+    groups: {
+      title: '按角色分组用户',
+      body: 'Bambuddy 自带默认分组:管理员、操作员、查看者。您也可以为自定义角色创建分组 — 比如只读的儿童账号、完全访问的伴侣账号,诸如此类。',
+    },
+    sso: {
+      title: '单点登录与 MFA',
+      body: 'OIDC(Authentik、Authelia、Keycloak、Google、GitHub)以及面向组织 SSO 的 SAML 2.0。每位用户可通过 TOTP 启用 MFA。加密密钥会在首次启动时自动生成;如需配合机密管理流程,可通过环境变量覆盖。',
+      configureOidc: '配置 OIDC',
+      configureSaml: '配置 SAML',
+      enableMfa: '为我的账号启用 MFA',
+    },
+    outro: {
+      title: '一切就绪 — 出问题时去这里看看',
+      system: '系统页 — 版本、日志、调试包、支持导出。',
+      diagnostic: '连接诊断 — 打印机连不上、摄像头黑屏、FTP 失败。可从打印机卡片菜单中打开。',
+      logScanner: '日志健康扫描器 — 标记反复出现的运行时问题,并附上已知修复建议。',
+      wiki: 'Wiki 位于 wiki.bambuddy.cool — 提供完整的功能文档。',
+      discord: 'Discord — 社区互助,对于使用问题比 GitHub 更快。',
+      github: 'GitHub Issues — 用于真正的 bug 与功能请求。',
+      rehome: '想再看一遍这个引导?它就在侧边栏的最下方。',
+    },
+    helpIcon: {
+      openWiki: '打开本节的维基页面',
+    },
+  },
 };

+ 192 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -6221,4 +6221,196 @@ export default {
     noReadAccess: '您沒有查看庫存預測的權限。',
     noWriteAccess: '您沒有修改預測設定的權限。',
   },
+
+  // 新手導覽
+  onboarding: {
+    button: {
+      next: '繼續',
+      back: '返回',
+      skip: '略過',
+      skipTour: '略過導覽',
+      done: '完成',
+      interested: '告訴我更多',
+      notInterested: '稍後再看',
+      remindLater: '稍後提醒我',
+    },
+    welcome: {
+      title: '歡迎使用 Bambuddy',
+      body: 'Bambuddy 以本地優先的儀表板取代 Bambu Lab 雲端。您的資料、列印紀錄、線軸與縮時影片皆保留在您自己的硬體上。要不要花五分鐘看看導覽?',
+      startTour: '開始導覽',
+      experienced: '我已經熟悉了',
+    },
+    about: {
+      title: 'Bambuddy 是什麼 — 不是什麼',
+      doesTitle: 'Bambuddy 能做的事',
+      doesBody: '在本地取代 Bambu 雲端、透過 RFID 追蹤 AMS 與線材庫存、執行列印佇列、封存每一次完成的列印、向您的切片軟體提供虛擬印表機、支援多使用者,並原生整合 Home Assistant 與 Tailscale。',
+      isntTitle: 'Bambuddy 不是什麼',
+      isntBody: '不是切片軟體(Bambuddy 將檔案交給 BambuStudio 或 OrcaSlicer 切片)、不是雲端服務、不是韌體工具、也不是 Klipper 介面。',
+      privacy: '沒有遙測。沒有帳號。bambuddy.cool 只提供文件。',
+    },
+    auth: {
+      title: '先把大門鎖好',
+      body: '若您網路、tailnet 或反向代理上的其他人能夠造訪此網址,請立即啟用登入驗證。密碼、OIDC、SAML 與 MFA 都已內建。',
+      severity: 'Bambuddy 可以控制您的印表機、管理您的檔案並讀取攝影機畫面 — 請把此網址當成管理後台來看待。',
+      enableNow: '立即啟用驗證',
+      later: '稍後 — 此網址是私人的',
+    },
+    addPrinter: {
+      title: '新增您的第一台印表機',
+      body: '您需要三項資訊:印表機型號、其在網路上的 IP 位址,以及存取碼。',
+      modelLabel: '型號',
+      modelHint: '由探索自動偵測,或在 Bambuddy 看不到印表機時手動選擇。',
+      ipLabel: 'IP 位址',
+      ipHint: '可於印表機螢幕的「設定 → 網路」查看。在路由器上設定 DHCP 保留可保持其穩定。',
+      codeLabel: '存取碼',
+      codeHint: '顯示在印表機螢幕上;路徑依機型而異 — 詳見下方提示。',
+      lanModeWarning: '請在印表機(X1、H2 與 P2S 系列)上啟用 LAN-only 模式。否則 MQTT 與 FTP 連接埠將維持封閉。',
+      devModeWarning: '請在印表機螢幕上啟用開發者模式 — 大多數機型的 MQTT 控制都需要它。',
+      dockerWarning: 'Docker bridge 使用者:探索可能找不到印表機。請改用手動輸入 IP 的方式。',
+      addViaDiscovery: '透過探索新增',
+      addManually: '依 IP 手動新增',
+    },
+    verifyConnection: {
+      title: '讓我們確認 Bambuddy 可以與它溝通',
+      mqttLabel: 'MQTT 控制(連接埠 8883)',
+      cameraLabel: '攝影機串流(RTSPS 連接埠 322,僅 X1 / H2 / P2S)',
+      ftpLabel: '檔案傳輸(FTP 連接埠 990)',
+      allGreen: '三個通道皆在 30 秒內成功連線至印表機。',
+      issuesFound: '發現 {{count}} 個問題 — 請開啟診斷以查看詳情。',
+      runDiagnostic: '執行完整診斷',
+    },
+    tourCard: {
+      title: '快速認識印表機卡片',
+      status: '狀態列 — 印表機狀態、預估剩餘時間、目前階段。一眼掌握情況。',
+      ams: 'AMS 列 — 槽位顏色與類型來自 RFID,其餘來自您的庫存;烘乾按鈕也在此處。',
+      camera: '攝影機方塊 — 與 BambuStudio 相同的即時串流,但在本地播放。不必繞道雲端。',
+      controls: '控制 — 暫停、繼續、取消、燈光、風扇 — 與印表機螢幕上的操作完全一致。',
+      customize: '在卡片上按右鍵即可重新排列方塊或隱藏不需要的項目。',
+    },
+    inventoryMode: {
+      title: '追蹤您的線材',
+      body: 'Bambuddy 可以替您管理線軸。請現在選擇一種模式 — 日後切換會遺失歷史資料。',
+      internalTitle: '內建(建議)',
+      internalBody: '內建庫存功能會鏡射 AMS、讀取 RFID,並在列印時自動扣除重量。對多數使用者最適合。',
+      spoolmanTitle: 'Spoolman',
+      spoolmanBody: '指向既有的 Spoolman 實例,Bambuddy 將從中同步。若您已在使用 Spoolman 是最佳選擇。',
+      noneTitle: '關閉',
+      noneBody: '完全跳過線材追蹤。日後仍可啟用,但過去的列印不會回補。',
+      footgun: '日後切換模式不會搬移您已輸入的資料。',
+    },
+    addSpool: {
+      title: '新增您的第一個線軸',
+      intro: '請挑選最適合您的方式:',
+      rfidTitle: 'RFID 掃描(Bambu 線軸)',
+      rfidBody: '將線軸裝入 AMS — Bambuddy 會自動讀取 RFID 標籤。無需手動輸入。',
+      spoolbuddyTitle: 'SpoolBuddy',
+      spoolbuddyBody: '若您有 SpoolBuddy 裝置,可為非 Bambu 線軸掃描可寫入的 RFID 標籤。',
+      manualTitle: '手動輸入',
+      manualBody: '品牌、材料、顏色、重量。',
+      catalog: '內建顏色目錄涵蓋主要品牌 — 輸入時會自動完成名稱。',
+      addManually: '手動新增',
+      useRfid: '使用 RFID',
+    },
+    spoolmanSync: {
+      title: '告訴 Bambuddy Spoolman 在哪裡',
+      body: 'Bambuddy 會匯入您既有的線軸資料並持續同步。RFID 掃描仍可使用 — 會直接在 Spoolman 內建立新線軸。',
+    },
+    bambuCloud: {
+      title: '從 Bambu Lab 同步您的線材與列印參數',
+      body: '若您在 BambuStudio 與 Bambu 雲端維護自訂的線材或列印參數,Bambuddy 可將它們匯入,讓網頁介面、佇列與虛擬印表機共用同一個資料庫。',
+      storage: '憑證以加密形式儲存在本機,且僅送往官方 Bambu API。原始碼公開。',
+      signIn: '登入 Bambu',
+      useDefaults: '略過 — 使用內建預設值',
+    },
+    sidebar: {
+      title: '快速瀏覽 Bambuddy 其他功能',
+      queue: '列印佇列 — 拖放排序、自動分派到閒置印表機、PETG 與 PA 的自動烘乾。',
+      archives: '封存庫 — 每一次完成的列印,附縮時影片、完成照片、gcode 與 3MF。可直接從任何一列重新列印。',
+      stats: '統計 — 時數、依品牌與材料與顏色分類的線材、能源成本(電價設定一次即可)、成功率。',
+      maintenance: '維護 — 噴嘴磨損、皮帶張力、熱端更換、潤滑週期。內建預設加上自訂任務。',
+      files: '檔案 — 您的 3MF、gcode 與 STL 資料庫。可上傳、加標籤、搜尋並送至任何印表機。外部根目錄可掛載 NAS 共享。',
+      projects: '專案 — 將檔案分組為一個邏輯專案並追蹤已列印的列盤。',
+      helpIcon: '每一頁右上角都有問號圖示,可在當下開啟對應的 wiki 頁面。',
+    },
+    vp: {
+      title: '虛擬印表機 — 讓切片軟體直接傳送至 Bambuddy',
+      body: 'BambuStudio 或 OrcaSlicer 可將列印工作送至 Bambuddy 而非 Bambu 雲端。請依您的環境選擇模式:',
+      bridgeTitle: '橋接模式',
+      bridgeBody: '雲端的即插即用替代品。切片軟體傳送至 Bambuddy,Bambuddy 再轉送至實際印表機。',
+      queueTitle: '佇列模式',
+      queueBody: '切片軟體傳送至一個虛擬收集器,由 Bambuddy 將工作排入佇列待分派。',
+      proxyTitle: '代理模式',
+      proxyBody: '切片軟體與 Bambuddy 對話,後者以完整的 MQTT、FTP 與 RTSP 改寫進行透通。適合多切片軟體環境。',
+      archiveTitle: '封存 / 審核',
+      archiveBody: '切片軟體傳送,Bambuddy 儲存但不列印。適合稽核與審核流程。',
+      ipNote: '虛擬印表機會在您的綁定介面上佔用一個閒置 IP,讓切片軟體把它當成真實印表機。',
+      dockerWarning: 'Docker bridge 模式需要明確開放連接埠 — 請參閱 Docker wiki 頁面了解 FTP 被動連接埠的切分方式。',
+      setUp: '設定虛擬印表機',
+    },
+    slicerApi: {
+      title: '從 MakerWorld 網址或您的資料庫切片 — 無需開啟 BambuStudio',
+      body: '需要 orca-slicer-api sidecar 容器(獨立的 docker-compose,連結見下方)。Bambuddy 透過 HTTP 與其溝通。',
+      status: '狀態:上游仍在成熟中 — 目前對單線材與單列盤工作穩定,多線材 3MF 的 segfault 問題正由上游修補中。',
+      configure: '設定 sidecar',
+    },
+    externalRoots: {
+      title: '掛載 NAS 共享、外接 SSD 或專案磁碟',
+      body: '在 docker-compose.yml 中設定 BAMBUDDY_EXTERNAL_ROOTS 並綁定主機路徑。Bambuddy 會自動在檔案管理器中顯示該資料夾。',
+      readOnlyWarning: '除非您明確希望使用者能寫回共享,否則請以唯讀模式掛載。',
+    },
+    makerworld: {
+      title: '貼上任何 MakerWorld 網址 — Bambuddy 會為您下載 3MF',
+      body: '此版本已移除應用內搜尋,請從 MakerWorld 網站貼上網址。匯入會遵循您的外部資料夾結構。',
+      tryNow: '立即試用',
+    },
+    obico: {
+      title: '自架 ML 列印失敗偵測 — 不需要 Obico 雲端帳號',
+      body: 'Bambuddy 直接與您自架的 Obico ML 伺服器溝通。每台印表機可個別啟用,預設關閉。',
+      smoothing: '平滑化與盲區調校位於各印表機專屬的 Obico 面板。',
+    },
+    integrations: {
+      title: 'Home Assistant 與 webhook',
+      body: '原生 Home Assistant 整合:每台印表機提供感測器(狀態、溫度、預估剩餘時間、AMS 槽位)以及啟動、暫停或取消的服務。Webhook 會在列印事件、佇列事件與封存事件時觸發。',
+      secret: 'Webhook 簽章密鑰位於「設定 → 整合」。',
+    },
+    tailscale: {
+      title: '透過您的 tailnet 從任何地方存取 Bambuddy',
+      body: '搭配 Let us Encrypt 的 MagicDNS HTTPS — Bambuddy 透過 tailscale cert 申請憑證並自行提供服務。',
+      openSettings: '開啟 Tailscale 設定',
+    },
+    notifications: {
+      title: '在列印完成、失敗或需要關注時得到通知',
+      body: '通道:應用內、瀏覽器推播、Discord、Telegram、Pushover、Gotify、ntfy、電子郵件與 webhook。事件過濾器可將 AMS 濕度警告送至 Discord、完成照片送至 Telegram,依此類推。',
+      configure: '立即設定',
+    },
+    users: {
+      title: '為您的團隊其他成員新增帳號',
+      body: '每位使用者都有自己的權限、列印歷史與通知設定。列印紀錄會顯示誰啟動了哪個工作。',
+      addUser: '新增使用者',
+    },
+    groups: {
+      title: '依角色將使用者分組',
+      body: 'Bambuddy 內建預設群組:管理員、操作員、檢視者。您可建立自己的群組以對應自訂角色 — 例如唯讀的小孩帳號、擁有完整權限的伴侶等等。',
+    },
+    sso: {
+      title: '單一登入與 MFA',
+      body: '組織 SSO 支援 OIDC(Authentik、Authelia、Keycloak、Google、GitHub)與 SAML 2.0。每位使用者可透過 TOTP 啟用 MFA。加密金鑰會在首次啟動時自動產生;可透過環境變數覆寫,以配合密鑰管理流程。',
+      configureOidc: '設定 OIDC',
+      configureSaml: '設定 SAML',
+      enableMfa: '為我的帳號啟用 MFA',
+    },
+    outro: {
+      title: '都設定好了 — 出狀況時請看這裡',
+      system: '系統頁面 — 版本、日誌、除錯包、支援匯出。',
+      diagnostic: '連線診斷 — 印表機無法連線、攝影機畫面全黑、FTP 失敗時使用。可從印表機卡片的選單開啟。',
+      logScanner: '日誌健康掃描器 — 標示反覆出現的執行階段問題,並提供已知的修復建議。',
+      wiki: 'Wiki 位於 wiki.bambuddy.cool — 完整的功能文件。',
+      discord: 'Discord — 社群協助,使用問題比 GitHub 更快得到回覆。',
+      github: 'GitHub Issues — 用於回報實際的錯誤與功能需求。',
+      rehome: '想再看一次此導覽?它就在側邊欄的最下方。',
+    },
+    helpIcon: {
+      openWiki: '開啟此區段的維基頁面',
+    },
+  },
 };

+ 2 - 0
frontend/src/pages/ArchivesPage.tsx

@@ -67,6 +67,7 @@ import { useIsMobile } from '../hooks/useIsMobile';
 import type { Archive, PrintLogEntry, ProjectListItem } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
+import { WikiHelpIcon } from '../components/WikiHelpIcon';
 import { PrintModal } from '../components/PrintModal';
 import { UploadModal } from '../components/UploadModal';
 import { PurgeArchivesModal } from '../components/PurgeArchivesModal';
@@ -3274,6 +3275,7 @@ export function ArchivesPage() {
           </div>
         </div>
         <div className="flex items-center gap-2 sm:gap-3 flex-wrap">
+          <WikiHelpIcon path="features/archives" />
           {/* Export dropdown */}
           <div className="relative">
             <Button

+ 2 - 0
frontend/src/pages/FileManagerPage.tsx

@@ -54,6 +54,7 @@ import type {
   Permission,
 } from '../api/client';
 import { Button } from '../components/Button';
+import { WikiHelpIcon } from '../components/WikiHelpIcon';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { PrintModal } from '../components/PrintModal';
 import { ModelViewerModal } from '../components/ModelViewerModal';
@@ -1457,6 +1458,7 @@ export function FileManagerPage() {
           </p>
         </div>
         <div className="flex items-center gap-2">
+          <WikiHelpIcon path="features/library" />
           {/* View mode toggle */}
           <div className="flex items-center bg-bambu-dark rounded-lg p-1">
             <button

+ 3 - 1
frontend/src/pages/InventoryPage.tsx

@@ -13,6 +13,7 @@ import { ForecastPanel } from '../components/ForecastPanel';
 import { api, spoolbuddyApi, ApiError } from '../api/client';
 import type { InventorySpool, SpoolCatalogEntry } from '../api/client';
 import { Button } from '../components/Button';
+import { WikiHelpIcon } from '../components/WikiHelpIcon';
 import { FilamentSwatch } from '../components/FilamentSwatch';
 import { buildFilamentBackground } from '../components/filamentSwatchHelpers';
 import {SpoolFormModal, type SpoolFormMode} from '../components/SpoolFormModal';
@@ -1129,6 +1130,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
           <p className="text-bambu-gray mt-1">{t('inventory.subtitle')}</p>
         </div>
         <div className="flex items-center gap-2">
+          <WikiHelpIcon path="features/inventory" />
           {/* CSV import/export (#1576). Operates on Bambuddy's local inventory.
               In Spoolman mode the buttons stay visible (feature parity) but are
               disabled with a hint pointing at Spoolman's own CSV export, since
@@ -2381,7 +2383,7 @@ function EmptyFilterState({
         }
       </p>
       {!hasFilters && (
-        <Button onClick={onAddSpool}>
+        <Button data-tour="add-spool-button" onClick={onAddSpool}>
           <Package className="w-4 h-4" />
           {t('inventory.addSpool')}
         </Button>

+ 21 - 17
frontend/src/pages/MaintenancePage.tsx

@@ -41,6 +41,7 @@ import type { MaintenanceStatus, PrinterMaintenanceOverview, MaintenanceType, Pe
 import { getMaintenanceWikiUrl } from '../utils/maintenanceWikiUrls';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
+import { WikiHelpIcon } from '../components/WikiHelpIcon';
 import { Toggle } from '../components/Toggle';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
@@ -1230,23 +1231,26 @@ export function MaintenancePage() {
   return (
     <div className="p-4 md:p-8">
       {/* Header */}
-      <div className="mb-6">
-        <h1 className="text-2xl font-bold text-white flex items-center gap-3">
-          <Wrench className="w-7 h-7 text-bambu-green" />
-          {t('maintenance.title')}
-        </h1>
-        <p className="text-bambu-gray mt-1">
-          {activeTab === 'status' ? (
-            <>
-              {totalDue > 0 && <span className="text-red-400">{t('maintenance.dueCount', { count: totalDue })}</span>}
-              {totalDue > 0 && totalWarning > 0 && ' · '}
-              {totalWarning > 0 && <span className="text-amber-400">{t('maintenance.warningCount', { count: totalWarning })}</span>}
-              {totalDue === 0 && totalWarning === 0 && <span className="text-bambu-green">{t('maintenance.allOk')}</span>}
-            </>
-          ) : (
-            t('maintenance.configureSettings')
-          )}
-        </p>
+      <div className="mb-6 flex items-start justify-between gap-4">
+        <div>
+          <h1 className="text-2xl font-bold text-white flex items-center gap-3">
+            <Wrench className="w-7 h-7 text-bambu-green" />
+            {t('maintenance.title')}
+          </h1>
+          <p className="text-bambu-gray mt-1">
+            {activeTab === 'status' ? (
+              <>
+                {totalDue > 0 && <span className="text-red-400">{t('maintenance.dueCount', { count: totalDue })}</span>}
+                {totalDue > 0 && totalWarning > 0 && ' · '}
+                {totalWarning > 0 && <span className="text-amber-400">{t('maintenance.warningCount', { count: totalWarning })}</span>}
+                {totalDue === 0 && totalWarning === 0 && <span className="text-bambu-green">{t('maintenance.allOk')}</span>}
+              </>
+            ) : (
+              t('maintenance.configureSettings')
+            )}
+          </p>
+        </div>
+        <WikiHelpIcon path="features/maintenance" />
       </div>
 
       {/* Tabs */}

+ 7 - 3
frontend/src/pages/PrintersPage.tsx

@@ -2528,6 +2528,7 @@ function PrinterCard({
             {/* Menu button */}
             <div className="relative flex-shrink-0">
               <Button
+                data-tour="printer-customize"
                 variant="ghost"
                 size="sm"
                 onClick={() => setShowMenu(!showMenu)}
@@ -2629,6 +2630,7 @@ function PrinterCard({
             <div className="flex flex-wrap items-center gap-2 mt-2">
               {/* Connection status badge */}
               <span
+                data-tour="printer-status-pill"
                 className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-xs ${
                   status?.connected
                     ? 'bg-status-ok/20 text-status-ok'
@@ -2888,7 +2890,7 @@ function PrinterCard({
               /* Expanded: Full status section */
               <>
                 {/* Current Print or Idle Placeholder */}
-                <div className="mb-4 p-3 bg-bambu-dark rounded-lg relative">
+                <div data-tour="printer-status-row" className="mb-4 p-3 bg-bambu-dark rounded-lg relative">
                   {/* Skip Objects button - top right corner, always visible */}
                   <button
                     onClick={() => setShowSkipObjectsModal(true)}
@@ -3130,7 +3132,7 @@ function PrinterCard({
               const chamberFan = status.big_fan2_speed;
 
               return (
-                <div className="mt-3">
+                <div data-tour="printer-controls" className="mt-3">
                   {/* Section Header */}
                   <div className="flex items-center gap-2 mb-2">
                     <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
@@ -3427,7 +3429,7 @@ function PrinterCard({
               const isDualNozzle = printer.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined;
 
               return (
-                <div className="mt-3">
+                <div data-tour="printer-ams-row" className="mt-3">
                   {/* Section Header */}
                   <div className="flex items-center gap-2 mb-2">
                     <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
@@ -4750,6 +4752,7 @@ function PrinterCard({
               </Button>
               {/* Camera Button */}
               <Button
+                data-tour="printer-camera"
                 variant="secondary"
                 size="sm"
                 onClick={() => {
@@ -7433,6 +7436,7 @@ export function PrintersPage() {
         </div>
       )}
       <Button
+        data-tour="add-printer-button"
         onClick={() => setShowAddModal(true)}
         disabled={!hasPermission('printers:create')}
         title={!hasPermission('printers:create') ? t('printers.permission.noAdd') : undefined}

+ 1 - 1
frontend/src/pages/ProfilesPage.tsx

@@ -160,7 +160,7 @@ function LoginForm({ onSuccess, t }: { onSuccess: () => void; t: TFunction }) {
   const isPending = loginMutation.isPending || verifyMutation.isPending || tokenMutation.isPending;
 
   return (
-    <Card className="max-w-md mx-auto">
+    <Card data-tour="bambu-cloud-sync" className="max-w-md mx-auto">
       <CardContent>
         <div className="text-center mb-6">
           <div className="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-bambu-green/20 mb-3">

+ 3 - 1
frontend/src/pages/ProjectsPage.tsx

@@ -27,6 +27,7 @@ import {
 import { api } from '../api/client';
 import type { ProjectListItem, ProjectCreate, ProjectUpdate, ProjectImport, Permission } from '../api/client';
 import { Button } from '../components/Button';
+import { WikiHelpIcon } from '../components/WikiHelpIcon';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
@@ -1043,7 +1044,8 @@ export function ProjectsPage() {
             {t('projects.subtitle')}
           </p>
         </div>
-        <div className="flex gap-2">
+        <div className="flex gap-2 items-center">
+          <WikiHelpIcon path="features/projects" />
           <Button
             variant="secondary"
             onClick={handleImportClick}

+ 2 - 0
frontend/src/pages/QueuePage.tsx

@@ -61,6 +61,7 @@ import { getBedTypeInfo } from '../utils/bedType';
 import type { PrintQueueItem, PrintQueueBulkUpdate, Permission } from '../api/client';
 import { Card } from '../components/Card';
 import { Button } from '../components/Button';
+import { WikiHelpIcon } from '../components/WikiHelpIcon';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { PrintModal } from '../components/PrintModal';
 import { useToast } from '../contexts/ToastContext';
@@ -1160,6 +1161,7 @@ export function QueuePage() {
           </h1>
           <p className="text-bambu-gray mt-1">{t('queue.subtitle')}</p>
         </div>
+        <WikiHelpIcon path="features/queue" />
       </div>
 
       {/* Summary Stats */}

+ 8 - 7
frontend/src/pages/SettingsPage.tsx

@@ -2736,7 +2736,7 @@ export function SettingsPage() {
         {/* Right Column - Home Assistant & MQTT Publishing */}
         <div className="flex-1 lg:max-w-xl space-y-3">
           {/* Home Assistant Integration */}
-          <Card id="card-ha">
+          <Card id="card-ha" data-tour="integrations-card">
             <CardHeader>
               <div className="flex items-center justify-between">
                 <h2 className="text-lg font-semibold text-white flex items-center gap-2">
@@ -4030,7 +4030,7 @@ export function SettingsPage() {
 
       {/* Virtual Printer Tab */}
       {activeTab === 'virtual-printer' && (
-        <div id="card-vp">
+        <div id="card-vp" data-tour="vp-card">
           <VirtualPrinterList />
         </div>
       )}
@@ -4280,7 +4280,7 @@ export function SettingsPage() {
           {/* Right Column */}
           <div className="lg:w-1/2 space-y-3">
           {/* Slicer */}
-          <Card id="card-slicer">
+          <Card id="card-slicer" data-tour="slicer-api-card">
             <CardHeader>
               <h3 className="text-base font-semibold text-white flex items-center gap-2">
                 <Cog className="w-4 h-4 text-bambu-green" />
@@ -5061,7 +5061,7 @@ export function SettingsPage() {
           {usersSubTab === 'users' && (
           <>
           {/* Auth Toggle Header */}
-          <Card>
+          <Card data-tour="auth-card">
             <CardContent className="py-4">
               <div className="flex items-center justify-between">
                 <div className="flex items-center gap-3">
@@ -5179,6 +5179,7 @@ export function SettingsPage() {
                       </h3>
                       {hasPermission('users:create') && (
                         <Button
+                          data-tour="add-user-button"
                           size="sm"
                           onClick={() => {
                             setShowCreateUserModal(true);
@@ -5255,7 +5256,7 @@ export function SettingsPage() {
 
               {/* Right Column: Groups */}
               <div>
-                <Card>
+                <Card data-tour="groups-section">
                   <CardHeader>
                     <div className="flex items-center justify-between">
                       <h3 className="text-lg font-semibold text-white flex items-center gap-2" id="card-groups">
@@ -5389,7 +5390,7 @@ export function SettingsPage() {
           )}
 
           {usersSubTab === 'oidc' && isAdmin && (
-            <div className="max-w-3xl">
+            <div className="max-w-3xl" data-tour="sso-section">
               <OIDCProviderSettings />
             </div>
           )}
@@ -5933,7 +5934,7 @@ export function SettingsPage() {
 
       {/* Backup Tab */}
       {activeTab === 'failure-detection' && (
-        <div id="card-failure-detection">
+        <div id="card-failure-detection" data-tour="obico-card">
           <FailureDetectionSettings />
         </div>
       )}

+ 2 - 0
frontend/src/pages/StatsPage.tsx

@@ -33,6 +33,7 @@ import {
   ResponsiveContainer,
 } from 'recharts';
 import { Button } from '../components/Button';
+import { WikiHelpIcon } from '../components/WikiHelpIcon';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { api, type ArchiveSlim } from '../api/client';
@@ -1177,6 +1178,7 @@ export function StatsPage() {
           <p className="text-bambu-gray mt-1">{t('stats.subtitle')}</p>
         </div>
         <div className="flex items-center gap-2 flex-wrap">
+          <WikiHelpIcon path="features/statistics" />
           {/* Hidden widgets button - toggles panel in Dashboard */}
           {hiddenCount > 0 && (
             <Button

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


BIN
static/img/bb_allset.webp


BIN
static/img/bb_almost.webp


BIN
static/img/bb_help.webp


BIN
static/img/bb_hero.webp


BIN
static/img/bb_started.webp


BIN
static/img/bb_walk.webp


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DdAEkh5e.js"></script>
+    <script type="module" crossorigin src="/assets/index-CKGCWfXP.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-7s3X35pi.css">
   </head>
   <body>

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