[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:
ciregenz
2026-05-04 01:00:43 -07:00
parent b0515ac3c5
commit 4ba7ed583e
17 changed files with 448 additions and 21 deletions
@@ -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;
}
+18 -1
View File
@@ -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 },
},
};