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
This commit is contained in:
Daniel Han 2026-06-10 08:22:17 -07:00 committed by GitHub
commit 18d851bfeb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 88 additions and 30 deletions

View file

@ -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 (
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center">
<img
src="/Sloth%20emojis/sloth%20shy%20large.png"
alt="Sloth mascot"
className="size-24"
/>
<MascotImg src="Sloth emojis/sloth shy large.png" className="size-24" />
<div className="flex flex-col items-center gap-1">
<h1 className="font-heading font-semibold text-2xl tracking-tight">
{t("shell.notFound.title")}

View file

@ -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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View file

@ -36,6 +36,7 @@ import {
useScrollThreadToBottom,
} from "@/components/assistant-ui/use-intent-aware-autoscroll";
import { Button } from "@/components/ui/button";
import { MascotImg } from "@/components/mascot-img";
import { Spinner } from "@/components/ui/spinner";
import {
DropdownMenu,
@ -781,7 +782,7 @@ const ThreadWelcome: FC<{
setWelcome(buildWelcome(new Date().getHours(), name));
}, [displayName]);
const currentEmojiSrc = `/Sloth emojis/${welcome.sloth}`;
const currentEmojiSrc = `Sloth emojis/${welcome.sloth}`;
return (
<div className="aui-thread-welcome-root mx-auto my-auto flex w-full max-w-(--thread-max-width) grow flex-col">
@ -789,9 +790,8 @@ const ThreadWelcome: FC<{
<div className="aui-thread-welcome-message flex w-full flex-col justify-center gap-9 px-4">
{/* Center the greeting (sloth + title) over the composer. */}
<div className="flex flex-row items-center justify-center gap-[15px]">
<img
<MascotImg
src={currentEmojiSrc}
alt="Sloth mascot"
className="size-[44px] -translate-y-[2px]"
/>
<h1 className="aui-thread-welcome-message-inner unsloth-welcome-title fade-in slide-in-from-bottom-1 animate-in text-3xl tracking-[-0.02em] duration-200">

View file

@ -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 <MascotImgInner key={props.src} {...props} />;
}
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<Stage>("primary");
const url = publicAssetUrl(src);
const effectiveSrc =
stage === "primary"
? url
: stage === "retry"
? `${url}${url.includes("?") ? "&" : "?"}retry=1`
: fallbackMascot;
return (
<img
draggable={false}
{...rest}
src={effectiveSrc}
alt={alt}
onError={() => setStage((s) => (s === "primary" ? "retry" : "fallback"))}
/>
);
}

View file

@ -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 (
<div className="w-full max-w-sm space-y-6">
<div className="space-y-1.5 text-center">
<img
src="/Sloth emojis/large sloth wave.png"
alt="Unsloth waving mascot"
<MascotImg
src="Sloth emojis/large sloth wave.png"
className="mx-auto mb-2 h-20 w-20 object-contain"
/>
<h2 className="text-2xl font-semibold text-foreground">{title}</h2>

View file

@ -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 (
<div className="flex h-full min-h-0 flex-col items-center justify-center bg-muted/10 px-6 text-center">
<div className="max-w-[30ch] space-y-1.5">
<img
src="/Sloth%20emojis/sloth%20w%20pc%20transparent.png"
alt=""
<MascotImg
src="Sloth emojis/sloth w pc transparent.png"
aria-hidden={true}
className="mx-auto mb-3 size-20 object-contain"
/>

View file

@ -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({
<Card className="w-full max-w-md px-8 py-8 shadow-border ring-1 ring-border">
{/* Mascot */}
<div className="flex justify-center">
<motion.img
src={`${import.meta.env.BASE_URL}Sloth emojis/Sloth loca pc.png`}
alt="Sloth mascot"
className="size-30"
<motion.div
initial={{ opacity: 0, y: 40, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{
@ -31,7 +29,9 @@ export function SplashScreen({
bounce: 0.3,
delay: 0.1,
}}
/>
>
<MascotImg src="Sloth emojis/Sloth loca pc.png" className="size-30" />
</motion.div>
</div>
{/* Brand text */}

View file

@ -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<StepNumber, string> = {
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 (
<main className="flex-1 flex flex-col overflow-y-auto">
<header className="flex flex-wrap items-start gap-3 p-4 pb-3 sm:p-6 sm:pb-4">
<img src={mascotSrc} alt="Unsloth mascot" className="size-12 sm:size-14" />
<MascotImg src={mascotSrc} className="size-12 sm:size-14" />
<div className="flex flex-col min-w-0">
<h1 className="text-lg font-semibold sm:text-xl">{stepConfig.title}</h1>
<p className="text-sm text-muted-foreground">

View file

@ -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 (
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center rounded-2xl bg-background/45 backdrop-blur-[1px]">
<div className="pointer-events-auto relative flex w-[860px] max-w-[calc(100%-2rem)] flex-col items-center">
<img
src="/unsloth-gem.png"
alt="Unsloth Studio"
className="size-24 object-contain"
/>
<MascotImg src="unsloth-gem.png" className="size-24 object-contain" />
<div className="relative w-full">
<AlertDialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}>
<Button