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:
parent
8129994061
commit
18d851bfeb
10 changed files with 88 additions and 30 deletions
|
|
@ -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")}
|
||||
|
|
|
|||
9
studio/frontend/src/asset-queries.d.ts
vendored
Normal file
9
studio/frontend/src/asset-queries.d.ts
vendored
Normal 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;
|
||||
}
|
||||
BIN
studio/frontend/src/assets/mascot-fallback.webp
Normal file
BIN
studio/frontend/src/assets/mascot-fallback.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
|
|
@ -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">
|
||||
|
|
|
|||
54
studio/frontend/src/components/mascot-img.tsx
Normal file
54
studio/frontend/src/components/mascot-img.tsx
Normal 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"))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue