mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-28 04:24:51 +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,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 },
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user