diff --git a/client/src/components/ui/Toast.jsx b/client/src/components/ui/Toast.jsx
index d33659447..254ee85f5 100644
--- a/client/src/components/ui/Toast.jsx
+++ b/client/src/components/ui/Toast.jsx
@@ -10,6 +10,7 @@
* own dismiss timer passes `collapseAfter` to fold on its schedule instead.
*/
+import { Loader2 } from 'lucide-react';
import { useState, useEffect, useRef } from 'react';
import { uuidv4 } from '../../lib/uuid.js';
@@ -118,8 +119,8 @@ export const toast = Object.assign(
export default toast;
-const TYPE_ICON = { success: '✓', error: '✕', loading: '⟳', warning: '⚠' };
-const TYPE_CLASS = { success: 'text-port-success', error: 'text-port-error', loading: 'text-gray-400 animate-spin', warning: 'text-port-warning' };
+const TYPE_ICON = { success: '✓', error: '✕', warning: '⚠' };
+const TYPE_CLASS = { success: 'text-port-success', error: 'text-port-error', loading: 'text-gray-400', warning: 'text-port-warning' };
export function Toaster({ position = 'bottom-right', toastOptions = {} }) {
const [items, setItems] = useState([]);
@@ -162,7 +163,23 @@ export function Toaster({ position = 'bottom-right', toastOptions = {} }) {
function ToastItem({ t, toastOptions }) {
const style = { padding: '12px 16px', borderRadius: '8px', ...toastOptions.style, ...t.style };
const iconStr = t.icon ?? (t.type !== 'default' ? TYPE_ICON[t.type] : null);
+ // The loading icon is `Loader2` — the same spinner the rest of the UI spins
+ // (ConfirmButtonPair, TabPills) — and no longer the `⟳` glyph it used to be.
+ // `animate-spin` rotates about the center of the element's box, and a glyph's
+ // ink is not centered in its line box: the font's ascent/descent padding above
+ // and below it is asymmetric, so the character is drawn off the box's midpoint
+ // and spinning it traces a visible wobble instead of a clean circle ("PortOS is
+ // restarting..." made it obvious, since that toast spins for the whole
+ // restart). An icon whose arc is centered in a square viewBox puts the visual
+ // center on the rotation origin, so it turns in place.
+ // A caller-supplied `icon` still wins over the spinner — it's an explicit
+ // override, and the type only picks the default.
+ const showSpinner = t.type === 'loading' && !t.icon;
+ const iconNode = showSpinner ? : iconStr;
const iconClass = t.type !== 'default' ? TYPE_CLASS[t.type] : '';
+ // The icon is 14px; centering it on the `text-sm` line box keeps it level
+ // with the first line of the message rather than riding above it.
+ const iconBoxClass = showSpinner ? 'inline-flex h-5 items-center' : '';
// Only a toast that never dismisses itself can outstay its welcome and start
// eating clicks — see COLLAPSE_AFTER_MS.
const collapsible = t.duration === Infinity;
@@ -256,7 +273,7 @@ function ToastItem({ t, toastOptions }) {
aria-label={collapsedLabel(t)}
className="pointer-events-auto flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-port-card border border-port-border shadow-lg text-sm"
>
- {iconStr ?? '•'}
+ {iconNode ?? '•'}
)}
setFocusWithin(true)}
onBlur={() => setFocusWithin(false)}
className="pointer-events-auto flex items-start gap-2 shadow-lg text-sm max-w-[calc(100vw-2rem)] sm:max-w-[520px] bg-port-card border border-port-border">
- {iconStr &&
{iconStr}}
+ {iconNode &&
{iconNode}}
{typeof t.content === 'function' ? t.content({ id: t.id }) : {t.content}}
diff --git a/client/src/components/ui/Toast.test.jsx b/client/src/components/ui/Toast.test.jsx
index b019b371c..28a67fa91 100644
--- a/client/src/components/ui/Toast.test.jsx
+++ b/client/src/components/ui/Toast.test.jsx
@@ -64,6 +64,33 @@ describe('Toaster accessibility', () => {
});
});
+describe('loading spinner', () => {
+ // Regression: the loading icon was the `⟳` glyph with `animate-spin` on it.
+ // The rotation origin is the center of the span's line box, but the glyph's
+ // ink sits off that point, so it wobbled instead of turning in place — very
+ // visible on "PortOS is restarting...", which spins for the whole restart.
+ // The spinning element must be an SVG whose arc is centered in its viewBox.
+ it('spins an SVG, never a text glyph', () => {
+ render(
);
+ act(() => { toast.loading('PortOS is restarting...'); });
+
+ const status = screen.getByRole('status');
+ const spinner = status.querySelector('.animate-spin');
+ expect(spinner?.tagName.toLowerCase()).toBe('svg');
+ // A glyph carried along by the rotation would reintroduce the wobble.
+ expect(spinner).toHaveTextContent('');
+ });
+
+ it('lets a caller-supplied icon override the spinner', () => {
+ render(
);
+ act(() => { toast.loading('Uploading', { icon: '⬆' }); });
+
+ const status = screen.getByRole('status');
+ expect(status.querySelector('[aria-hidden="true"]')).toHaveTextContent('⬆');
+ expect(status.querySelector('.animate-spin')).toBeNull();
+ });
+});
+
/** Why a never-dismissing toast has to fold away: see COLLAPSE_AFTER_MS. */
describe('long-lived toasts stop blocking the page', () => {
const advance = (ms) => act(() => { vi.advanceTimersByTime(ms); });