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.
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 7 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 23 KiB |
|
|
@ -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
scripts/slice_bb_mascot.py
Normal file
|
|
@ -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()
|
||||||
8753
static/assets/index-CKGCWfXP.js
Normal file
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 7 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 23 KiB |