From 18d851bfebd43d4b39de16e5f31d5c8b5b9bb50f Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 10 Jun 2026 08:22:17 -0700 Subject: [PATCH] Studio: mascot images degrade gracefully instead of showing alt text (#6146) * Studio: mascot images degrade gracefully instead of showing alt text Mascots loaded from raw public-folder URLs with no error handling, so a missing or unreachable file (version skew during updates, subpath mounts, transient blips) painted the alt text, like the empty mascot on the training start overlay. A shared MascotImg now resolves against BASE_URL, retries once with a cache-buster, then swaps to a 4 KB fallback sloth bundled as a data URI that cannot 404. Applied to the chat greeting, 404 page, training start overlay, auth form, onboarding splash and wizard. * Reset mascot retry state via key remount instead of render-time tracking * Use MascotImg for the artifact generating panel sloth --- studio/frontend/src/app/router.tsx | 7 +-- studio/frontend/src/asset-queries.d.ts | 9 +++ .../frontend/src/assets/mascot-fallback.webp | Bin 0 -> 4374 bytes .../src/components/assistant-ui/thread.tsx | 6 +- studio/frontend/src/components/mascot-img.tsx | 54 ++++++++++++++++++ .../features/auth/components/auth-form.tsx | 6 +- .../chat/artifacts/artifact-surface.tsx | 6 +- .../onboarding/components/splash-screen.tsx | 10 ++-- .../onboarding/components/wizard-content.tsx | 13 +++-- .../studio/training-start-overlay.tsx | 7 +-- 10 files changed, 88 insertions(+), 30 deletions(-) create mode 100644 studio/frontend/src/asset-queries.d.ts create mode 100644 studio/frontend/src/assets/mascot-fallback.webp create mode 100644 studio/frontend/src/components/mascot-img.tsx diff --git a/studio/frontend/src/app/router.tsx b/studio/frontend/src/app/router.tsx index dbb74ee1a1..5c18e637e2 100644 --- a/studio/frontend/src/app/router.tsx +++ b/studio/frontend/src/app/router.tsx @@ -3,6 +3,7 @@ import { Link, createRouter, useRouterState } from "@tanstack/react-router"; import { Button } from "@/components/ui/button"; +import { MascotImg } from "@/components/mascot-img"; import { useT } from "@/i18n"; import { Route as rootRoute } from "./routes/__root"; import { Route as dataRecipesRoute } from "./routes/data-recipes"; @@ -41,11 +42,7 @@ function DefaultNotFound() { return (
- Sloth mascot +

{t("shell.notFound.title")} diff --git a/studio/frontend/src/asset-queries.d.ts b/studio/frontend/src/asset-queries.d.ts new file mode 100644 index 0000000000..2c7a1cd936 --- /dev/null +++ b/studio/frontend/src/asset-queries.d.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +// Vite `?inline` imports (data URIs); vite/client only types bare extensions. +declare module "*?inline" { + const src: string; + // biome-ignore lint/style/noDefaultExport: Vite asset modules export default. + export default src; +} diff --git a/studio/frontend/src/assets/mascot-fallback.webp b/studio/frontend/src/assets/mascot-fallback.webp new file mode 100644 index 0000000000000000000000000000000000000000..ad50361dbbdfbd483f0eb92fbe49b5c7628a81a1 GIT binary patch literal 4374 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 @@ -789,9 +790,8 @@ const ThreadWelcome: FC<{
{/* Center the greeting (sloth + title) over the composer. */}
- Sloth mascot

diff --git a/studio/frontend/src/components/mascot-img.tsx b/studio/frontend/src/components/mascot-img.tsx new file mode 100644 index 0000000000..cff4d54c4d --- /dev/null +++ b/studio/frontend/src/components/mascot-img.tsx @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { type ComponentProps, useState } from "react"; + +// Bundled as a data URI so the fallback ships inside the JS bundle and can +// never 404, unlike the public-folder originals. +import fallbackMascot from "@/assets/mascot-fallback.webp?inline"; + +const LEADING_SLASH = /^\//; + +// Resolve a public-folder asset against the deploy base (subpath mounts) and +// encode the spaced sloth filenames. +export function publicAssetUrl(path: string): string { + return encodeURI(import.meta.env.BASE_URL + path.replace(LEADING_SLASH, "")); +} + +type MascotImgProps = { src: string } & Omit< + ComponentProps<"img">, + "src" | "onError" +>; + +// Keying on src remounts the inner component, so the retry state resets +// whenever the source changes (greeting sloths rotate). +export function MascotImg(props: MascotImgProps) { + return ; +} + +type Stage = "primary" | "retry" | "fallback"; + +// Decorative mascot that degrades gracefully: a failed load retries once with +// a cache-buster (transient blips, stale caches), then swaps to the bundled +// fallback sloth. Empty alt by default so a broken image never paints text. +function MascotImgInner({ src, alt = "", ...rest }: MascotImgProps) { + const [stage, setStage] = useState("primary"); + + const url = publicAssetUrl(src); + const effectiveSrc = + stage === "primary" + ? url + : stage === "retry" + ? `${url}${url.includes("?") ? "&" : "?"}retry=1` + : fallbackMascot; + + return ( + {alt} setStage((s) => (s === "primary" ? "retry" : "fallback"))} + /> + ); +} diff --git a/studio/frontend/src/features/auth/components/auth-form.tsx b/studio/frontend/src/features/auth/components/auth-form.tsx index f2017ff0b6..d109b5f196 100644 --- a/studio/frontend/src/features/auth/components/auth-form.tsx +++ b/studio/frontend/src/features/auth/components/auth-form.tsx @@ -3,6 +3,7 @@ import { apiUrl } from "@/lib/api-base"; import { Button } from "@/components/ui/button"; +import { MascotImg } from "@/components/mascot-img"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Link, useNavigate } from "@tanstack/react-router"; @@ -311,9 +312,8 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null { return (
- Unsloth waving mascot

{title}

diff --git a/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx b/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx index a685515253..22066b6fc3 100644 --- a/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx +++ b/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx @@ -9,6 +9,7 @@ import { unslothDarkTheme, unslothLightTheme, } from "@/components/assistant-ui/code-themes"; +import { MascotImg } from "@/components/mascot-img"; import { Button } from "@/components/ui/button"; import { copyToClipboard } from "@/lib/copy-to-clipboard"; import { cn } from "@/lib/utils"; @@ -78,9 +79,8 @@ function ArtifactGeneratingPanel() { return (
- diff --git a/studio/frontend/src/features/onboarding/components/splash-screen.tsx b/studio/frontend/src/features/onboarding/components/splash-screen.tsx index 70438a04d8..47d9c25efe 100644 --- a/studio/frontend/src/features/onboarding/components/splash-screen.tsx +++ b/studio/frontend/src/features/onboarding/components/splash-screen.tsx @@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; +import { MascotImg } from "@/components/mascot-img"; import { motion } from "motion/react"; interface SplashScreenProps { @@ -19,10 +20,7 @@ export function SplashScreen({ {/* Mascot */}
- + > + +
{/* Brand text */} diff --git a/studio/frontend/src/features/onboarding/components/wizard-content.tsx b/studio/frontend/src/features/onboarding/components/wizard-content.tsx index 6906b87f8f..40d290a73e 100644 --- a/studio/frontend/src/features/onboarding/components/wizard-content.tsx +++ b/studio/frontend/src/features/onboarding/components/wizard-content.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 +import { MascotImg } from "@/components/mascot-img"; import { STEPS } from "@/config/training"; import { useTrainingConfigStore } from "@/features/training"; import type { StepNumber } from "@/types/training"; @@ -19,11 +20,11 @@ const STEP_COMPONENTS = { } as const; const STEP_MASCOTS: Record = { - 1: `${import.meta.env.BASE_URL}Sloth emojis/large sloth wave.png`, - 2: `${import.meta.env.BASE_URL}Sloth emojis/sloth magnify final.png`, - 3: `${import.meta.env.BASE_URL}Sloth emojis/sloth huglove large.png`, - 4: `${import.meta.env.BASE_URL}Sloth emojis/large sloth glasses.png`, - 5: `${import.meta.env.BASE_URL}Sloth emojis/large sloth yay.png`, + 1: "Sloth emojis/large sloth wave.png", + 2: "Sloth emojis/sloth magnify final.png", + 3: "Sloth emojis/sloth huglove large.png", + 4: "Sloth emojis/large sloth glasses.png", + 5: "Sloth emojis/large sloth yay.png", }; export function WizardContent() { @@ -35,7 +36,7 @@ export function WizardContent() { return (
- Unsloth mascot +

{stepConfig.title}

diff --git a/studio/frontend/src/features/studio/training-start-overlay.tsx b/studio/frontend/src/features/studio/training-start-overlay.tsx index b9fb34fedb..3b0cce658b 100644 --- a/studio/frontend/src/features/studio/training-start-overlay.tsx +++ b/studio/frontend/src/features/studio/training-start-overlay.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 +import { MascotImg } from "@/components/mascot-img"; import { AlertDialog, AlertDialogAction, @@ -312,11 +313,7 @@ export function TrainingStartOverlay({ return (

- Unsloth Studio +