studio: fix toast close-button click and light-mode hover (#5597)

Two related issues on the chat toasts:

1. Close X did nothing. The lib/toast.ts wrapper defaulted every toast
   to `dismissible: false` (originally to keep swipe capture from
   stealing text selection). In sonner v2, `dismissible: false` makes
   the close-button onClick a no-op, so the X looked clickable but
   never dismissed the toast. The Toaster already sets
   `swipeDirections={[]}` in components/ui/sonner.tsx, so the
   per-toast swipe workaround is unnecessary and harmful. Replace the
   wrapper with a thin re-export of sonner.

2. Close X hover collapsed to a near-black circle in light mode.
   Sonner's default close-button styling uses fixed gray-scale tokens
   (--gray2 hover, --gray12 text) that ignore the theme attribute.
   Once the Toaster's inline style overrides --normal-bg with
   var(--popover), the base background follows the app theme but the
   hover state does not, so the hover bg lands on a color that has no
   contrast with the X glyph. Pin both base and hover to theme tokens
   (--popover, --muted, --popover-foreground, --border) so contrast
   stays visible in both light and dark modes.

Repro: open chat, load any cached model, hover the X on the
"<name> loaded" toast in light mode -- before this change the circle
turned dark and the click did nothing; after, the circle stays light
and the click dismisses the toast.
This commit is contained in:
Michael Han 2026-05-19 00:55:55 -07:00 committed by GitHub
commit c4908b7929
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 12 additions and 56 deletions

View file

@ -1172,11 +1172,18 @@
mix-blend-mode: normal;
}
/* Override sonner's hardcoded top: 0 on the toast close button. */
/* Override sonner top: 0 and pin to theme tokens (--gray2 hover ignores data-sonner-theme). */
[data-sonner-toast][data-styled="true"] [data-close-button] {
top: 8px !important;
background: var(--popover) !important;
color: var(--popover-foreground) !important;
border-color: var(--border) !important;
}
/* Bump the X stroke so it stays visible against dark backgrounds. */
[data-sonner-toast][data-styled="true"] [data-close-button] svg {
stroke-width: 2.25;
}
[data-sonner-toast][data-styled="true"]:hover [data-close-button]:hover {
background: var(--muted) !important;
color: var(--popover-foreground) !important;
border-color: var(--border) !important;
}

View file

@ -1,59 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
// sonner `toast` wrapper that defaults `dismissible: false` so swipe
// capture doesn't block text selection. Drop-in for `from "sonner"`.
import { toast as sonnerToast, type ExternalToast } from "sonner";
type AnyFn = (...args: unknown[]) => unknown;
function withDismissibleFalse<F extends AnyFn>(fn: F): F {
return ((...args: unknown[]) => {
// Branch by arity: React-element messages are objects too.
if (args.length <= 1) {
args.push({ dismissible: false } satisfies ExternalToast);
} else {
const lastIdx = args.length - 1;
const last = args[lastIdx];
if (last && typeof last === "object" && !Array.isArray(last)) {
const opts = last as ExternalToast;
if (!("dismissible" in opts)) {
args[lastIdx] = { dismissible: false, ...opts };
}
}
}
return fn(...args);
}) as F;
}
const wrappedCallable = withDismissibleFalse(
sonnerToast as unknown as AnyFn,
) as typeof sonnerToast;
// `promise(p, data?)` carries `dismissible` at the top of `data`,
// covering loading / success / error states. `dismiss`, `getHistory`,
// `getToasts` take no options.
const wrappedPromise: typeof sonnerToast.promise = ((promise, data) => {
const merged =
data && typeof data === "object" && !("dismissible" in data)
? { dismissible: false, ...data }
: (data ?? { dismissible: false });
return sonnerToast.promise(promise, merged);
}) as typeof sonnerToast.promise;
export const toast: typeof sonnerToast = Object.assign(wrappedCallable, {
success: withDismissibleFalse(sonnerToast.success.bind(sonnerToast) as AnyFn) as typeof sonnerToast.success,
info: withDismissibleFalse(sonnerToast.info.bind(sonnerToast) as AnyFn) as typeof sonnerToast.info,
warning: withDismissibleFalse(sonnerToast.warning.bind(sonnerToast) as AnyFn) as typeof sonnerToast.warning,
error: withDismissibleFalse(sonnerToast.error.bind(sonnerToast) as AnyFn) as typeof sonnerToast.error,
message: withDismissibleFalse(sonnerToast.message.bind(sonnerToast) as AnyFn) as typeof sonnerToast.message,
loading: withDismissibleFalse(sonnerToast.loading.bind(sonnerToast) as AnyFn) as typeof sonnerToast.loading,
custom: withDismissibleFalse(sonnerToast.custom.bind(sonnerToast) as AnyFn) as typeof sonnerToast.custom,
promise: wrappedPromise,
dismiss: sonnerToast.dismiss.bind(sonnerToast) as typeof sonnerToast.dismiss,
getHistory: sonnerToast.getHistory.bind(sonnerToast) as typeof sonnerToast.getHistory,
getToasts: sonnerToast.getToasts.bind(sonnerToast) as typeof sonnerToast.getToasts,
});
// Re-export of sonner. Swipe blocking lives on the Toaster via
// `swipeDirections={[]}`, so no per-toast dismissible override.
export { toast } from "sonner";
export type { ExternalToast } from "sonner";