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" > - + )}
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 && } + {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); });