mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-07 02:07:45 +02:00
[eric] perceived-speed pass — skeleton placeholders replace spinners on Tools/Modes/Views/DiffViewer/DashboardSelection, dashboard rename
updates instantly (rollback if server rejects), dashboard cards no longer shake neighbors during streaming, plus shared motion/loading primitives for future consistency. Toolbar model/mode/thinking pick now writes through to the global default so it sticks across reopens.
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { DURATION_MS, EASE } from '@/shared/styles/motionTokens';
|
||||
import { useReducedMotion } from '@/shared/hooks/useReducedMotion';
|
||||
|
||||
/**
|
||||
* Smooth visual transitions for status pills + counters that currently snap.
|
||||
*
|
||||
* <CrossFadeOnChange value={x}>{(v) => <span>{v}</span>}</CrossFadeOnChange>
|
||||
* Old value fades to 30% while new value fades in. Cancels on rapid changes.
|
||||
*
|
||||
* <TweeningNumber value={1234} format={(n) => `$${n.toFixed(4)}`} />
|
||||
* RAF-tweens from previous to new value. Caps duration on big jumps.
|
||||
*/
|
||||
|
||||
interface CrossFadeProps<T> {
|
||||
value: T;
|
||||
children: (currentValue: T) => React.ReactNode;
|
||||
/** Defaults to DURATION_MS.quick (140ms). */
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export function CrossFadeOnChange<T>({ value, children, durationMs }: CrossFadeProps<T>) {
|
||||
const reduced = useReducedMotion();
|
||||
const dur = reduced ? 0 : (durationMs ?? DURATION_MS.quick);
|
||||
const [displayed, setDisplayed] = useState(value);
|
||||
const [opacity, setOpacity] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.is(displayed, value)) return;
|
||||
if (dur === 0) {
|
||||
setDisplayed(value);
|
||||
return;
|
||||
}
|
||||
// Fade old to ~0, then swap and fade new in.
|
||||
setOpacity(0);
|
||||
const t = setTimeout(() => {
|
||||
setDisplayed(value);
|
||||
setOpacity(1);
|
||||
}, dur / 2);
|
||||
return () => clearTimeout(t);
|
||||
}, [value, dur, displayed]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
opacity,
|
||||
transition: `opacity ${dur / 2}ms ${EASE.out}`,
|
||||
}}
|
||||
>
|
||||
{children(displayed)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface TweeningNumberProps {
|
||||
value: number;
|
||||
/** How to render the tweened number. Default: `n.toString()`. */
|
||||
format?: (n: number) => string;
|
||||
/** Cap on tween duration regardless of delta. Default 500ms. */
|
||||
maxDurationMs?: number;
|
||||
}
|
||||
|
||||
export const TweeningNumber: React.FC<TweeningNumberProps> = ({
|
||||
value,
|
||||
format = (n) => String(Math.round(n)),
|
||||
maxDurationMs = 500,
|
||||
}) => {
|
||||
const reduced = useReducedMotion();
|
||||
const [displayed, setDisplayed] = useState(value);
|
||||
const startedAtRef = useRef<number | null>(null);
|
||||
const fromRef = useRef<number>(value);
|
||||
const toRef = useRef<number>(value);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (reduced) {
|
||||
setDisplayed(value);
|
||||
return;
|
||||
}
|
||||
if (Object.is(toRef.current, value)) return;
|
||||
|
||||
fromRef.current = displayed;
|
||||
toRef.current = value;
|
||||
startedAtRef.current = performance.now();
|
||||
|
||||
// Duration scales with delta but caps. ~1ms per unit, capped.
|
||||
const delta = Math.abs(value - fromRef.current);
|
||||
const dur = Math.min(maxDurationMs, Math.max(120, delta * 1.2));
|
||||
|
||||
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
||||
|
||||
const step = (now: number) => {
|
||||
const t = Math.min(1, (now - (startedAtRef.current as number)) / dur);
|
||||
// ease-out cubic
|
||||
const eased = 1 - Math.pow(1 - t, 3);
|
||||
const current = fromRef.current + (toRef.current - fromRef.current) * eased;
|
||||
setDisplayed(current);
|
||||
if (t < 1) {
|
||||
rafRef.current = requestAnimationFrame(step);
|
||||
} else {
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(step);
|
||||
|
||||
return () => {
|
||||
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [value, reduced, maxDurationMs]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return <>{format(displayed)}</>;
|
||||
};
|
||||
@@ -334,8 +334,9 @@ const AppShell: React.FC = () => {
|
||||
|
||||
const handleDashboardRenameSubmit = (id: string) => {
|
||||
const trimmed = renameValue.trim();
|
||||
if (trimmed && trimmed !== dashboardItems[id]?.name) {
|
||||
dispatch(renameDashboard({ id, name: trimmed }));
|
||||
const previousName = dashboardItems[id]?.name;
|
||||
if (trimmed && trimmed !== previousName) {
|
||||
dispatch(renameDashboard({ id, name: trimmed, previousName }));
|
||||
}
|
||||
setRenamingDashboardId(null);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { DURATION_MS, EASE, pulseKeyframes } from '@/shared/styles/motionTokens';
|
||||
import { useReducedMotion } from '@/shared/hooks/useReducedMotion';
|
||||
|
||||
/**
|
||||
* Unified loading primitives. Three components, one aesthetic.
|
||||
*
|
||||
* <Skeleton variant="card|line|circle" width height />
|
||||
* For full-component / full-page loads. Replaces decorative spinners.
|
||||
*
|
||||
* <InlineSpinner size />
|
||||
* For inline button states + OAuth waits. Spinner = "I'm doing it now".
|
||||
*
|
||||
* <EmptyState icon title hint />
|
||||
* For "nothing here yet" empty lists. Replaces ad-hoc "Loading..." text.
|
||||
*
|
||||
* `delayMs` (Skeleton + EmptyState): don't show until N ms have elapsed.
|
||||
* Prevents the flash-of-skeleton on fast loads (<100ms common case).
|
||||
*/
|
||||
|
||||
interface SkeletonProps {
|
||||
variant?: 'card' | 'line' | 'circle' | 'custom';
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
/** Default 100ms; pass 0 to render immediately */
|
||||
delayMs?: number;
|
||||
}
|
||||
|
||||
export const Skeleton: React.FC<SkeletonProps> = ({
|
||||
variant = 'line',
|
||||
width,
|
||||
height,
|
||||
delayMs = 100,
|
||||
}) => {
|
||||
const c = useClaudeTokens();
|
||||
const reduced = useReducedMotion();
|
||||
const [show, setShow] = useState(delayMs === 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (delayMs === 0) return;
|
||||
const t = setTimeout(() => setShow(true), delayMs);
|
||||
return () => clearTimeout(t);
|
||||
}, [delayMs]);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
const dimensions: React.CSSProperties = {
|
||||
width: width ?? (variant === 'card' ? '100%' : variant === 'circle' ? 24 : '60%'),
|
||||
height: height ?? (variant === 'card' ? 80 : variant === 'circle' ? 24 : 12),
|
||||
};
|
||||
|
||||
const radius = variant === 'circle'
|
||||
? '50%'
|
||||
: variant === 'card'
|
||||
? 8
|
||||
: 4;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
...dimensions,
|
||||
borderRadius: `${typeof radius === 'number' ? `${radius}px` : radius}`,
|
||||
bgcolor: c.border.subtle,
|
||||
opacity: 0.5,
|
||||
animation: reduced ? 'none' : `openswarmPulse ${DURATION_MS.ambient}ms ${EASE.pulse} infinite`,
|
||||
...pulseKeyframes,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface InlineSpinnerProps {
|
||||
/** 14 / 16 / 18; defaults to 16 */
|
||||
size?: 14 | 16 | 18 | 20;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const InlineSpinner: React.FC<InlineSpinnerProps> = ({ size = 16, color }) => {
|
||||
const c = useClaudeTokens();
|
||||
return <CircularProgress size={size} sx={{ color: color ?? c.text.tertiary }} />;
|
||||
};
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
hint?: string;
|
||||
/** Show after N ms — keeps "Loading..." flash off fast paths */
|
||||
delayMs?: number;
|
||||
}
|
||||
|
||||
export const EmptyState: React.FC<EmptyStateProps> = ({ icon, title, hint, delayMs = 100 }) => {
|
||||
const c = useClaudeTokens();
|
||||
const [show, setShow] = useState(delayMs === 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (delayMs === 0) return;
|
||||
const t = setTimeout(() => setShow(true), delayMs);
|
||||
return () => clearTimeout(t);
|
||||
}, [delayMs]);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 1.5,
|
||||
py: 6,
|
||||
px: 3,
|
||||
color: c.text.tertiary,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{icon && <Box sx={{ opacity: 0.5, fontSize: 32 }}>{icon}</Box>}
|
||||
<Typography sx={{ fontSize: '0.9rem', fontWeight: 500, color: c.text.muted }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{hint && (
|
||||
<Typography sx={{ fontSize: '0.75rem', color: c.text.ghost, maxWidth: 320 }}>
|
||||
{hint}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import DifferenceIcon from '@mui/icons-material/Difference';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import { Skeleton } from '@/app/components/Loading';
|
||||
|
||||
const AGENTS_API = `${API_BASE}/agents`;
|
||||
|
||||
@@ -100,7 +101,11 @@ const DiffViewer: React.FC<Props> = ({ sessionId }) => {
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.8rem' }}>Loading...</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{[0, 1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<Skeleton key={i} variant="line" width={`${60 + (i * 7) % 30}%`} height={10} />
|
||||
))}
|
||||
</Box>
|
||||
) : diff ? (
|
||||
<pre
|
||||
style={{
|
||||
|
||||
@@ -611,6 +611,8 @@ const AgentCard: React.FC<Props> = ({
|
||||
}}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
// contain: streaming chat updates inside don't reflow the dashboard.
|
||||
contain: 'layout style',
|
||||
width: localResize ? activeW : Math.max(cardWidth, MIN_W),
|
||||
height: localResize ? activeH : (expanded ? Math.max(EXPANDED_OVERLAY_H, cardHeight) : 'auto'),
|
||||
bgcolor: c.bg.surface,
|
||||
|
||||
@@ -626,6 +626,8 @@ const BrowserCard: React.FC<Props> = ({
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
// contain: webview repaints don't shake neighbor cards.
|
||||
contain: 'layout style',
|
||||
left: displayX,
|
||||
top: displayY,
|
||||
width: displayW,
|
||||
|
||||
@@ -19,6 +19,8 @@ import { useElementSelection } from '@/app/components/ElementSelectionContext';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { searchHistory, clearHistorySearch } from '@/shared/state/agentsSlice';
|
||||
import { updateSettings, AppSettings } from '@/shared/state/settingsSlice';
|
||||
import { store } from '@/shared/state/store';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
import type { Output } from '@/shared/state/outputsSlice';
|
||||
|
||||
@@ -127,6 +129,32 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
}
|
||||
prevInputOpen.current = inputOpen;
|
||||
}, [inputOpen, settingsLoaded, defaultMode, defaultModel, defaultThinkingLevel]);
|
||||
|
||||
// Picking a model/mode/thinking-level in the toolbar writes through to
|
||||
// the global default. Without this, the reopen-reset effect above
|
||||
// would snap back to the old default the next time the user opens the
|
||||
// toolbar, ignoring what they last picked.
|
||||
const promoteToDefault = useCallback(<K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
|
||||
const current = store.getState().settings;
|
||||
if (!current.loaded) return;
|
||||
if (current.data[key] === value) return;
|
||||
dispatch(updateSettings({ ...current.data, [key]: value }));
|
||||
}, [dispatch]);
|
||||
|
||||
const handleModeChange = useCallback((newMode: string) => {
|
||||
setMode(newMode);
|
||||
promoteToDefault('default_mode', newMode);
|
||||
}, [promoteToDefault]);
|
||||
|
||||
const handleModelChange = useCallback((newModel: string) => {
|
||||
setModel(newModel);
|
||||
promoteToDefault('default_model', newModel);
|
||||
}, [promoteToDefault]);
|
||||
|
||||
const handleThinkingLevelChange = useCallback((level: 'off' | 'low' | 'medium' | 'high' | 'auto') => {
|
||||
setThinkingLevel(level);
|
||||
promoteToDefault('default_thinking_level', level);
|
||||
}, [promoteToDefault]);
|
||||
const [viewPickerOpen, setViewPickerOpen] = useState(false);
|
||||
const [viewSearch, setViewSearch] = useState('');
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
@@ -384,14 +412,14 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
onModeChange={handleModeChange}
|
||||
model={model}
|
||||
onModelChange={setModel}
|
||||
onModelChange={handleModelChange}
|
||||
embedded
|
||||
autoFocus
|
||||
sessionId={TOOLBAR_OWNER_ID}
|
||||
thinkingLevel={thinkingLevel}
|
||||
onThinkingLevelChange={setThinkingLevel}
|
||||
onThinkingLevelChange={handleThinkingLevelChange}
|
||||
/>
|
||||
</div>
|
||||
) : historyOpen ? (
|
||||
|
||||
@@ -307,6 +307,8 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
// contain: iframe app repaints don't shake the rest of the dashboard.
|
||||
contain: 'layout style',
|
||||
left: displayX,
|
||||
top: displayY,
|
||||
width: displayW,
|
||||
|
||||
@@ -267,6 +267,8 @@ const NoteCard: React.FC<Props> = ({
|
||||
top: displayY,
|
||||
width: displayW,
|
||||
height: displayH,
|
||||
// contain: reflow inside this note doesn't shake the dashboard.
|
||||
contain: 'layout style',
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
bgcolor: palette.bg,
|
||||
border: isHighlighted
|
||||
|
||||
@@ -13,6 +13,7 @@ import ListItemText from '@mui/material/ListItemText';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import { Skeleton } from '@/app/components/Loading';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
@@ -107,8 +108,9 @@ const DashboardSelection: React.FC = () => {
|
||||
|
||||
const handleRenameSubmit = (id: string) => {
|
||||
const trimmed = renameValue.trim();
|
||||
if (trimmed && trimmed !== items[id]?.name) {
|
||||
dispatch(renameDashboard({ id, name: trimmed }));
|
||||
const previousName = items[id]?.name;
|
||||
if (trimmed && trimmed !== previousName) {
|
||||
dispatch(renameDashboard({ id, name: trimmed, previousName }));
|
||||
}
|
||||
setRenamingId(null);
|
||||
};
|
||||
@@ -174,9 +176,11 @@ const DashboardSelection: React.FC = () => {
|
||||
</Box>
|
||||
|
||||
{loading ? (
|
||||
<Typography sx={{ color: c.text.muted, textAlign: 'center', py: 8 }}>
|
||||
Loading...
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, py: 4 }}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Skeleton key={i} variant="card" height={64} />
|
||||
))}
|
||||
</Box>
|
||||
) : dashboards.length === 0 ? (
|
||||
<Box sx={{ textAlign: 'center', py: 10, color: c.text.muted }}>
|
||||
<Typography sx={{ fontSize: '1.1rem', mb: 1 }}>
|
||||
|
||||
@@ -14,6 +14,7 @@ import IconButton from '@mui/material/IconButton';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import { Skeleton } from '@/app/components/Loading';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Select from '@mui/material/Select';
|
||||
@@ -242,8 +243,10 @@ const Modes: React.FC = () => {
|
||||
</Box>
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
||||
<CircularProgress sx={{ color: c.accent.primary }} />
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 2, mt: 1 }}>
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<Skeleton key={i} variant="card" height={120} />
|
||||
))}
|
||||
</Box>
|
||||
) : modes.length === 0 ? (
|
||||
<Box
|
||||
|
||||
@@ -87,6 +87,7 @@ import {
|
||||
updateOutput,
|
||||
Output,
|
||||
} from '@/shared/state/outputsSlice';
|
||||
import { Skeleton } from '@/app/components/Loading';
|
||||
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
@@ -1384,7 +1385,11 @@ const Tools: React.FC = () => {
|
||||
</Box>
|
||||
<Collapse in={customSectionOpen}>
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 6 }}><CircularProgress sx={{ color: c.accent.primary }} size={28} /></Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, pl: 1, mt: 1 }}>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} variant="card" height={72} />
|
||||
))}
|
||||
</Box>
|
||||
) : (tools.length === 0 && uninstalledIntegrations.length === 0) ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', py: 6, color: c.text.ghost, gap: 1.5 }}>
|
||||
<BuildIcon sx={{ fontSize: 40, opacity: 0.3 }} />
|
||||
@@ -1923,8 +1928,10 @@ const Tools: React.FC = () => {
|
||||
</Box>
|
||||
|
||||
{regLoading && regServers.length === 0 ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flex: 1 }}>
|
||||
<CircularProgress sx={{ color: c.accent.primary }} size={28} />
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, flex: 1, py: 1 }}>
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<Skeleton key={i} variant="card" height={56} />
|
||||
))}
|
||||
</Box>
|
||||
) : regServers.length === 0 ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', flex: 1, color: c.text.ghost, gap: 1.5 }}>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { fetchOutputs, deleteOutput, Output } from '@/shared/state/outputsSlice';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import ViewCard from './ViewCard';
|
||||
import { Skeleton } from '@/app/components/Loading';
|
||||
import ViewEditor from './ViewEditor';
|
||||
import ViewRunDialog from './ViewRunDialog';
|
||||
|
||||
@@ -115,9 +116,11 @@ const Views: React.FC = () => {
|
||||
|
||||
{/* Card grid */}
|
||||
{loading ? (
|
||||
<Typography sx={{ color: c.text.muted, textAlign: 'center', py: 8 }}>
|
||||
Loading...
|
||||
</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 2, py: 2 }}>
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<Skeleton key={i} variant="card" height={140} />
|
||||
))}
|
||||
</Box>
|
||||
) : outputs.length === 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
const QUERY = '(prefers-reduced-motion: reduce)';
|
||||
|
||||
function subscribe(callback: () => void): () => void {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
|
||||
const mql = window.matchMedia(QUERY);
|
||||
// Modern + legacy event names both supported.
|
||||
mql.addEventListener('change', callback);
|
||||
return () => mql.removeEventListener('change', callback);
|
||||
}
|
||||
|
||||
function getSnapshot(): boolean {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return false;
|
||||
return window.matchMedia(QUERY).matches;
|
||||
}
|
||||
|
||||
function getServerSnapshot(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the OS-level "Reduce motion" preference is on.
|
||||
* Mac: System Settings → Accessibility → Display → Reduce Motion.
|
||||
* Windows: Settings → Ease of Access → Display → Show animations.
|
||||
*
|
||||
* Reactive — flips immediately if the user toggles the OS setting
|
||||
* mid-session (rare but supported).
|
||||
*/
|
||||
export function useReducedMotion(): boolean {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: returns 0 when reduced-motion is on, otherwise the supplied
|
||||
* duration. Use inline at animation sites:
|
||||
*
|
||||
* const dur = useMotionDuration(DURATION_MS.quick);
|
||||
* <Fade timeout={dur}>...</Fade>
|
||||
*
|
||||
* For animations that convey causality (modal open, drawer slide), prefer a
|
||||
* tiny non-zero floor so the user still perceives the transition:
|
||||
*
|
||||
* const dur = useMotionDuration(DURATION_MS.standard, { floor: 40 });
|
||||
*/
|
||||
export function useMotionDuration(ms: number, opts: { floor?: number } = {}): number {
|
||||
const reduced = useReducedMotion();
|
||||
if (!reduced) return ms;
|
||||
return opts.floor ?? 0;
|
||||
}
|
||||
@@ -42,12 +42,13 @@ export const createDashboard = createAsyncThunk(
|
||||
|
||||
export const renameDashboard = createAsyncThunk(
|
||||
'dashboards/rename',
|
||||
async ({ id, name }: { id: string; name: string }) => {
|
||||
async ({ id, name }: { id: string; name: string; previousName?: string }) => {
|
||||
const res = await fetch(`${DASHBOARDS_API}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`rename failed: ${res.status}`);
|
||||
return (await res.json()) as Dashboard;
|
||||
},
|
||||
);
|
||||
@@ -116,6 +117,16 @@ const dashboardsSlice = createSlice({
|
||||
.addCase(createDashboard.fulfilled, (state, action) => {
|
||||
state.items[action.payload.id] = action.payload;
|
||||
})
|
||||
// Optimistic: update name immediately on dispatch so the sidebar
|
||||
// entry / picker label swaps with no perceptible lag. Server confirms
|
||||
// on .fulfilled (rare correction); .rejected rolls back to previousName.
|
||||
.addCase(renameDashboard.pending, (state, action) => {
|
||||
const { id, name } = action.meta.arg;
|
||||
if (state.items[id]) {
|
||||
state.items[id].name = name;
|
||||
state.items[id].auto_named = false;
|
||||
}
|
||||
})
|
||||
.addCase(renameDashboard.fulfilled, (state, action) => {
|
||||
const d = action.payload;
|
||||
if (state.items[d.id]) {
|
||||
@@ -127,6 +138,12 @@ const dashboardsSlice = createSlice({
|
||||
};
|
||||
}
|
||||
})
|
||||
.addCase(renameDashboard.rejected, (state, action) => {
|
||||
const { id, previousName } = action.meta.arg;
|
||||
if (state.items[id] && previousName !== undefined) {
|
||||
state.items[id].name = previousName;
|
||||
}
|
||||
})
|
||||
.addCase(deleteDashboard.fulfilled, (state, action) => {
|
||||
delete state.items[action.payload];
|
||||
})
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Single source of truth for animation timing + easing across the app.
|
||||
// Mixing one-off durations / curves makes the chrome feel like several
|
||||
// different products glued together; tokenizing makes everything land
|
||||
// the same way.
|
||||
//
|
||||
// Pair with `useReducedMotion()` to respect OS-level "Reduce motion".
|
||||
|
||||
export const DURATION_MS = {
|
||||
/** 60ms — hover state changes, subtle press feedback */
|
||||
instant: 60,
|
||||
/** 140ms — rows fading in, popovers, tooltip open, status pill swaps */
|
||||
quick: 140,
|
||||
/** 220ms — modal open, page transitions, banners */
|
||||
standard: 220,
|
||||
/** 400ms — drawer slide, big layout shifts */
|
||||
slow: 400,
|
||||
/** 1500ms — skeleton pulse + ambient breathing indicators */
|
||||
ambient: 1500,
|
||||
} as const;
|
||||
|
||||
export const EASE = {
|
||||
/** Linear's signature curve. Snappy out, gentle settle. Good default for "thing appears". */
|
||||
out: 'cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
/** MUI / Material default. Symmetric — for things that move both directions. */
|
||||
inOut: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
/** Subtle bounce at the end. Use sparingly for delight moments. */
|
||||
spring: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
/** Gentle breathing curve for ambient pulses. */
|
||||
pulse: 'cubic-bezier(0.4, 0, 0.6, 1)',
|
||||
} as const;
|
||||
|
||||
/** Framer-motion uses array-form easing. Same curves as EASE above. */
|
||||
export const FRAMER_EASE = {
|
||||
out: [0.16, 1, 0.3, 1] as [number, number, number, number],
|
||||
inOut: [0.4, 0, 0.2, 1] as [number, number, number, number],
|
||||
spring: [0.34, 1.56, 0.64, 1] as [number, number, number, number],
|
||||
pulse: [0.4, 0, 0.6, 1] as [number, number, number, number],
|
||||
};
|
||||
|
||||
/** Module-scoped fadeIn keyframe. Imported once instead of redefined inline at each callsite. */
|
||||
export const fadeInKeyframes = {
|
||||
'@keyframes openswarmFadeIn': {
|
||||
from: { opacity: 0 },
|
||||
to: { opacity: 1 },
|
||||
},
|
||||
};
|
||||
|
||||
/** Skeleton + indicator pulse keyframe. Imported once. */
|
||||
export const pulseKeyframes = {
|
||||
'@keyframes openswarmPulse': {
|
||||
'0%, 100%': { opacity: 0.5 },
|
||||
'50%': { opacity: 0.25 },
|
||||
},
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user