فهرست منبع

fix(onboarding): step counter ignores skipped steps; transparent + tight BB poses

    Three bugs in the tour shipped in the previous commit:

    - The step counter displayed raw index over total, so when add-printer
      auto-skipped (printer already configured), the first visible step
      rendered as "2 / 25". Counter now shows visible position over visible
      total — that user sees "1 / 24". Same trick handles every other
      skipIf-gated step (makerworld permission, sso/users when auth off).

    - The BB mascot crops were lossy webp with no alpha, so the off-white
      sheet background showed as a hard rectangle against the dark-theme
      tour card. Re-sliced from the source character sheet as lossless
      webp RGBA with a soft alpha ramp on the background, so the mascot
      composites cleanly.

    - The same crops included the caption text below each pose ("Almost
      there!" etc.), visible as a sliver under the character. Tightened
      the Y range to exclude the caption row.

    scripts/slice_bb_mascot.py captures the pose coordinates and the
    keyout ramp so a future character-sheet re-export is reproducible
    rather than nudged by eye.
maziggy 2 ماه پیش
والد
کامیت
a5d75cdbf3

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


+ 8 - 1
frontend/src/components/onboarding/TourEngine.tsx

@@ -221,6 +221,13 @@ export function TourEngine() {
   const modalPos = computeModalPosition(anchorRect);
   const modalPos = computeModalPosition(anchorRect);
   const isLastStep = stepIndex === TOUR_STEPS.length - 1;
   const isLastStep = stepIndex === TOUR_STEPS.length - 1;
   const isFirstStep = stepIndex === 0;
   const isFirstStep = stepIndex === 0;
+  // Step counter shows visible position, not raw index — when add-printer
+  // auto-skips because the user already has printers, the next step is the
+  // user's first visible one and should render as "1 / N", not "2 / N".
+  const visibleTotal = TOUR_STEPS.filter((s) => !s.skipIf?.(skipContext)).length;
+  const visiblePosition = TOUR_STEPS.slice(0, stepIndex + 1).filter(
+    (s) => !s.skipIf?.(skipContext),
+  ).length;
 
 
   return (
   return (
     <>
     <>
@@ -240,7 +247,7 @@ export function TourEngine() {
           <div className="flex items-center gap-3 mb-3">
           <div className="flex items-center gap-3 mb-3">
             <MascotIcon pose={step.pose ?? 'hero'} className="w-12 h-12 flex-shrink-0" />
             <MascotIcon pose={step.pose ?? 'hero'} className="w-12 h-12 flex-shrink-0" />
             <div className="text-xs text-bambu-gray">
             <div className="text-xs text-bambu-gray">
-              {stepIndex + 1} / {TOUR_STEPS.length}
+              {visiblePosition} / {visibleTotal}
             </div>
             </div>
           </div>
           </div>
           <h3 id="tour-step-title" className="text-lg font-semibold text-white mb-2">
           <h3 id="tour-step-title" className="text-lg font-semibold text-white mb-2">

+ 70 - 0
scripts/slice_bb_mascot.py

@@ -0,0 +1,70 @@
+"""Re-slice BB mascot poses from the character sheet.
+
+Produces the per-pose webp files consumed by MascotIcon (the onboarding tour).
+
+Source:  screenshots/onboarding/bb_bambuddy.webp (RGB, no alpha, opaque bg)
+Output:  frontend/public/img/bb_{hero,started,walk,almost,allset,help}.webp
+
+Two things matter and must not regress:
+  - the crop excludes the caption text under each pose, so the tour modal
+    never shows a sliver of "Almost there!" under the character;
+  - the background is keyed out to alpha so the mascot composites cleanly
+    on the dark-theme tour card.
+
+Pose coordinates are pixel offsets in the source sheet, derived once by
+column/row density analysis. If the character sheet is re-exported with
+different dimensions, re-derive them rather than nudging by eye.
+"""
+
+from pathlib import Path
+
+import numpy as np
+from PIL import Image
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+SRC = REPO_ROOT / "screenshots/bb images/bb_bambuddy.webp"
+OUT_DIR = REPO_ROOT / "frontend/public/img"
+
+POSE_Y = (746, 924)
+POSES = {
+    "started": (35, 206),
+    "walk": (251, 423),
+    "almost": (455, 631),
+    "allset": (657, 851),
+    "help": (861, 1079),
+}
+HERO_BOX = (50, 71, 620, 650)
+
+# Background ramp: pixels with min-channel >= BG_FULL go fully transparent,
+# pixels with min-channel <= INK stay fully opaque, in between alpha ramps
+# linearly so anti-aliased outlines keep their feathering.
+BG_FULL = 228
+INK = 200
+
+
+def keyout_to_alpha(crop: Image.Image) -> Image.Image:
+    arr = np.array(crop.convert("RGB"))
+    min_ch = arr.min(axis=2).astype(np.int32)
+    alpha = np.clip((BG_FULL - min_ch) * 255 // (BG_FULL - INK), 0, 255).astype(np.uint8)
+    return Image.fromarray(np.dstack([arr, alpha]), mode="RGBA")
+
+
+def save_webp_lossless(im: Image.Image, path: Path) -> None:
+    im.save(path, "WEBP", lossless=True, quality=100, method=6)
+
+
+def main() -> None:
+    src = Image.open(SRC)
+    for name, (x0, x1) in POSES.items():
+        crop = src.crop((x0, POSE_Y[0], x1, POSE_Y[1]))
+        out_path = OUT_DIR / f"bb_{name}.webp"
+        save_webp_lossless(keyout_to_alpha(crop), out_path)
+        print(f"  {out_path.name}  {crop.size}")
+
+    hero_crop = src.crop(HERO_BOX)
+    save_webp_lossless(keyout_to_alpha(hero_crop), OUT_DIR / "bb_hero.webp")
+    print(f"  bb_hero.webp  {hero_crop.size}")
+
+
+if __name__ == "__main__":
+    main()

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-CKGCWfXP.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


برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است