studio/frontend: fix onboarding CSP violations (#5658)

* studio/frontend: fix onboarding CSP violations

Two onboarding-only CSP violations were showing up in the browser
console on a default install:

* `WizardSidebar` rendered the brand sticker from
  `https://unsloth.ai/cgi/image/unsloth_sticker_no_shadow_*.png`,
  which is not in the Studio CSP `img-src` allowlist. The sticker
  rendered as a broken image.
* `Confetti` defaulted `globalOptions.useWorker` to `true`, so
  `canvas-confetti` tried to spawn an OffscreenCanvas worker from
  a `blob:` URL. CSP `script-src 'self'` blocks it; three blocked-
  worker errors fired on the final wizard step.

Use the bundled `/sticker.png` for the brand image, and default
the Confetti wrapper to the main-thread fallback. CSP stays tight.

Resolves #5657.

* studio/frontend: harden CSP confetti fix + BASE_URL sticker

Address review feedback on #5658:

1. confetti.tsx
   - Hoist the default globalOptions to a module-scope constant so the
     prop default has a stable identity across renders (canvasRef's
     dependency array no longer churns every render).
   - Always force useWorker:false at the confetti.create site, regardless
     of what the caller passed in globalOptions. Previously a caller that
     set `{ resize: true }` would silently re-enable the worker and trip
     the CSP block again.
   - Add a lazily-mounted, module-scoped CSP-safe instance and route
     ConfettiButton through it instead of the global confetti() (which
     defaults to useWorker:true and would otherwise violate CSP).

2. confetti-fireworks.ts
   - Replace the direct confetti(...) calls (global instance, default
     worker on) with calls to a shared confetti.create instance with
     useWorker:false. The guided-tour completion confetti no longer
     trips the CSP block.

3. wizard-sidebar.tsx
   - Use import.meta.env.BASE_URL prefix on the sticker src so the asset
     still resolves when Studio is deployed under a subpath (e.g.
     /studio/). Defaults to "/" so single-host installs are unchanged.

tsc clean, bun run build clean, bundle confirms the changes
(`{resize:!0,useWorker:!1}` appears in every relevant call site).

* studio/tour: preserve opts.zIndex on shared confetti fireworks canvas

Address chatgpt-codex-connector inline review on #5658 follow-up:

When canvas-confetti runs against a caller-provided canvas (which is
what we need for the CSP fix), the per-fire `zIndex` option is ignored
for stacking purposes -- the canvas element's own CSS `z-index` is what
the browser uses. The previous follow-up hard-coded the shared canvas
to `z-index:99999`, so callers that pass `opts.zIndex` (or expect the
old global-confetti behavior of being able to lower fireworks under an
overlay) silently lost that knob.

Apply `opts.zIndex` to the shared canvas's `style.zIndex` on each call
(default 99999 still used when omitted). Same default; behavior is now
restored for the lower/raise case.

The current only caller (`guided-tour.tsx` invoking
`fireConfettiFireworks()` with no args) is unaffected since it never
provided `opts.zIndex`. Public API contract is preserved.

* studio/frontend: drop dead ConfettiButton + BASE_URL onboarding mascots

- confetti.tsx: remove unused ConfettiButton + getSharedConfettiFire singleton (0 callsites)
- splash-screen.tsx, wizard-content.tsx: prefix sloth mascot paths with import.meta.env.BASE_URL so onboarding works under non-root subpaths
- confetti-fireworks.ts: drop dead per-fire zIndex from defaults (caller-provided canvas ignores it; we already drive stacking via canvas style)

* studio/frontend: BASE_URL on HF icon + race-safe shared fireworks init

- dataset-step.tsx: prefix the Hugging Face dataset-source icon with import.meta.env.BASE_URL so it resolves correctly under non-root deployments. Last onboarding asset that was still root-relative after the earlier BASE_URL sweep.
- confetti-fireworks.ts: cache the in-flight init promise in getSharedFire so two same-tick callers share the dynamic import and the appended overlay canvas. Previously two concurrent fireConfettiFireworks() calls each appended a fixed full-screen canvas and orphaned the first one.

* studio/frontend: tighten confetti CSP comments

---------

Co-authored-by: danielhanchen <michaelhan2050@gmail.com>
This commit is contained in:
Daniel Han 2026-05-22 05:02:19 -07:00 committed by GitHub
commit 71232f749a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 166 additions and 159 deletions

View file

@ -1,150 +1,118 @@
// 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 {
GlobalOptions as ConfettiGlobalOptions,
CreateTypes as ConfettiInstance,
Options as ConfettiOptions,
} from "canvas-confetti";
import confetti from "canvas-confetti";
import type { ReactNode } from "react";
import type React from "react";
import {
createContext,
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
} from "react";
import { Button } from "@/components/ui/button";
type Api = {
fire: (options?: ConfettiOptions) => void;
};
type Props = React.ComponentPropsWithRef<"canvas"> & {
options?: ConfettiOptions;
globalOptions?: ConfettiGlobalOptions;
manualstart?: boolean;
children?: ReactNode;
};
export type ConfettiRef = Api | null;
const ConfettiContext = createContext<Api>({} as Api);
// Define component first
const ConfettiComponent = forwardRef<ConfettiRef, Props>((props, ref) => {
const {
options,
globalOptions = { resize: true, useWorker: true },
manualstart = false,
children,
...rest
} = props;
const instanceRef = useRef<ConfettiInstance | null>(null);
const canvasRef = useCallback(
(node: HTMLCanvasElement) => {
if (node !== null) {
if (instanceRef.current) return;
instanceRef.current = confetti.create(node, {
...globalOptions,
resize: true,
});
} else {
if (instanceRef.current) {
instanceRef.current.reset();
instanceRef.current = null;
}
}
},
[globalOptions],
);
const fire = useCallback(
async (opts = {}) => {
try {
await instanceRef.current?.({ ...options, ...opts });
} catch (error) {
console.error("Confetti error:", error);
}
},
[options],
);
const api = useMemo(
() => ({
fire,
}),
[fire],
);
useImperativeHandle(ref, () => api, [api]);
useEffect(() => {
if (!manualstart) {
(async () => {
try {
await fire();
} catch (error) {
console.error("Confetti effect error:", error);
}
})();
}
}, [manualstart, fire]);
return (
<ConfettiContext.Provider value={api}>
<canvas ref={canvasRef} {...rest} />
{children}
</ConfettiContext.Provider>
);
});
// Set display name immediately
ConfettiComponent.displayName = "Confetti";
// Export as Confetti
export const Confetti = ConfettiComponent;
interface ConfettiButtonProps extends React.ComponentProps<"button"> {
options?: ConfettiOptions &
ConfettiGlobalOptions & { canvas?: HTMLCanvasElement };
}
const ConfettiButtonComponent = ({
options,
children,
...props
}: ConfettiButtonProps) => {
const handleClick = async (event: React.MouseEvent<HTMLButtonElement>) => {
try {
const rect = event.currentTarget.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
await confetti({
...options,
origin: {
x: x / window.innerWidth,
y: y / window.innerHeight,
},
});
} catch (error) {
console.error("Confetti button error:", error);
}
};
return (
<Button onClick={handleClick} {...props}>
{children}
</Button>
);
};
ConfettiButtonComponent.displayName = "ConfettiButton";
export const ConfettiButton = ConfettiButtonComponent;
import type {
GlobalOptions as ConfettiGlobalOptions,
CreateTypes as ConfettiInstance,
Options as ConfettiOptions,
} from "canvas-confetti";
import confetti from "canvas-confetti";
import type { ReactNode } from "react";
import type React from "react";
import {
createContext,
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
} from "react";
type Api = {
fire: (options?: ConfettiOptions) => void;
};
type Props = React.ComponentPropsWithRef<"canvas"> & {
options?: ConfettiOptions;
globalOptions?: ConfettiGlobalOptions;
manualstart?: boolean;
children?: ReactNode;
};
export type ConfettiRef = Api | null;
const ConfettiContext = createContext<Api>({} as Api);
// Studio CSP blocks canvas-confetti's default blob: worker, so force
// useWorker: false. Module-scoped so the prop default keeps stable
// identity across renders (`canvasRef` depends on `globalOptions`).
const DEFAULT_GLOBAL_OPTIONS: ConfettiGlobalOptions = {
resize: true,
useWorker: false,
};
const ConfettiComponent = forwardRef<ConfettiRef, Props>((props, ref) => {
const {
options,
globalOptions = DEFAULT_GLOBAL_OPTIONS,
manualstart = false,
children,
...rest
} = props;
const instanceRef = useRef<ConfettiInstance | null>(null);
const canvasRef = useCallback(
(node: HTMLCanvasElement) => {
if (node !== null) {
if (instanceRef.current) return;
instanceRef.current = confetti.create(node, {
...globalOptions,
resize: true,
// Force off after the spread so caller globalOptions can't
// re-enable the worker and trip CSP.
useWorker: false,
});
} else {
if (instanceRef.current) {
instanceRef.current.reset();
instanceRef.current = null;
}
}
},
[globalOptions],
);
const fire = useCallback(
async (opts = {}) => {
try {
await instanceRef.current?.({ ...options, ...opts });
} catch (error) {
console.error("Confetti error:", error);
}
},
[options],
);
const api = useMemo(
() => ({
fire,
}),
[fire],
);
useImperativeHandle(ref, () => api, [api]);
useEffect(() => {
if (!manualstart) {
(async () => {
try {
await fire();
} catch (error) {
console.error("Confetti effect error:", error);
}
})();
}
}, [manualstart, fire]);
return (
<ConfettiContext.Provider value={api}>
<canvas ref={canvasRef} {...rest} />
{children}
</ConfettiContext.Provider>
);
});
ConfettiComponent.displayName = "Confetti";
export const Confetti = ConfettiComponent;

View file

@ -20,7 +20,7 @@ export function SplashScreen({
{/* Mascot */}
<div className="flex justify-center">
<motion.img
src="/Sloth emojis/Sloth loca pc.png"
src={`${import.meta.env.BASE_URL}Sloth emojis/Sloth loca pc.png`}
alt="Sloth mascot"
className="size-30"
initial={{ opacity: 0, y: 40, scale: 0.95 }}

View file

@ -151,7 +151,7 @@ export function DatasetStep() {
className="flex-1"
>
<img
src="/huggingface.svg"
src={`${import.meta.env.BASE_URL}huggingface.svg`}
alt=""
className="size-4 invert"
data-icon="inline-start"

View file

@ -19,11 +19,11 @@ const STEP_COMPONENTS = {
} as const;
const STEP_MASCOTS: Record<StepNumber, string> = {
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",
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`,
};
export function WizardContent() {

View file

@ -18,7 +18,7 @@ export function WizardSidebar({ returnTo }: { returnTo: string }) {
<aside className="w-full shrink-0 bg-muted/70 p-4 md:w-64 md:p-6">
<div className="flex items-center gap-3 py-1 md:py-2">
<img
src="https://unsloth.ai/cgi/image/unsloth_sticker_no_shadow_ldN4V4iydw00qSIIWDCUv.png?width=96&quality=80&format=auto"
src={`${import.meta.env.BASE_URL}sticker.png`}
alt="Unsloth"
className="size-12"
/>

View file

@ -3,12 +3,48 @@
"use client";
import type { CreateTypes as ConfettiInstance } from "canvas-confetti";
type FireworksOpts = {
durationMs?: number;
intervalMs?: number;
zIndex?: number;
};
// CSP blocks canvas-confetti's default blob: worker, so reuse a single
// overlay canvas via `confetti.create(..., { useWorker: false })`.
// Caller-provided canvases ignore the per-fire `zIndex`; stacking is
// driven by `_sharedCanvas.style.zIndex` instead (set in fireConfettiFireworks).
const DEFAULT_FIREWORKS_Z_INDEX = 99999;
let _sharedCanvas: HTMLCanvasElement | null = null;
let _sharedFire: ConfettiInstance | null = null;
// Cache the init promise so concurrent callers share one import + canvas.
let _sharedFirePromise: Promise<ConfettiInstance | null> | null = null;
function getSharedFire(): Promise<ConfettiInstance | null> {
if (typeof document === "undefined") return Promise.resolve(null);
if (_sharedFire) return Promise.resolve(_sharedFire);
if (_sharedFirePromise) return _sharedFirePromise;
_sharedFirePromise = (async () => {
try {
const confetti = (await import("canvas-confetti")).default;
const canvas = document.createElement("canvas");
canvas.style.cssText =
`position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:${DEFAULT_FIREWORKS_Z_INDEX}`;
document.body.appendChild(canvas);
_sharedCanvas = canvas;
_sharedFire = confetti.create(canvas, {
resize: true,
useWorker: false,
});
return _sharedFire;
} catch (err) {
_sharedFirePromise = null;
throw err;
}
})();
return _sharedFirePromise;
}
export async function fireConfettiFireworks(opts: FireworksOpts = {}) {
try {
if (typeof window === "undefined") return;
@ -17,7 +53,11 @@ export async function fireConfettiFireworks(opts: FireworksOpts = {}) {
window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false;
if (prefersReduce) return;
const confetti = (await import("canvas-confetti")).default;
const fire = await getSharedFire();
if (!fire || !_sharedCanvas) return;
// Per-fire zIndex is ignored on a shared canvas; drive stacking via CSS.
_sharedCanvas.style.zIndex = String(opts.zIndex ?? DEFAULT_FIREWORKS_Z_INDEX);
const duration = opts.durationMs ?? 1200;
const intervalMs = opts.intervalMs ?? 240;
@ -26,7 +66,6 @@ export async function fireConfettiFireworks(opts: FireworksOpts = {}) {
startVelocity: 28,
spread: 360,
ticks: 58,
zIndex: opts.zIndex ?? 99999,
disableForReducedMotion: true,
} as const;
@ -45,12 +84,12 @@ export async function fireConfettiFireworks(opts: FireworksOpts = {}) {
Math.floor(36 * (timeLeft / duration)),
);
confetti({
fire({
...defaults,
particleCount,
origin: { x: randomInRange(0.12, 0.3), y: Math.random() - 0.2 },
});
confetti({
fire({
...defaults,
particleCount,
origin: { x: randomInRange(0.7, 0.88), y: Math.random() - 0.2 },