feat(frontend): content micro-animations — stagger, button press, toast polish
- animate-stagger utility + staggerDelay() helper; lists now rise in sequence (recordings grid, moderation rows, message list, media queue, dashboard tiles). - Button gets a subtle active:scale-[0.97] press feedback. - Toaster: toast-in slide-up, tone-accent border, rounded hover-close target. - All motion is reduced-motion aware (killed under prefers-reduced-motion).
This commit is contained in:
@@ -29,6 +29,7 @@ import {
|
|||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
import { formatNumber } from "@/lib/format";
|
import { formatNumber } from "@/lib/format";
|
||||||
import type { DashboardStats } from "@/lib/types";
|
import type { DashboardStats } from "@/lib/types";
|
||||||
|
import { staggerDelay } from "@/lib/utils";
|
||||||
|
|
||||||
function deriveSignal(stats?: DashboardStats) {
|
function deriveSignal(stats?: DashboardStats) {
|
||||||
if (!stats) return { tone: "signal" as const, label: "nominal" };
|
if (!stats) return { tone: "signal" as const, label: "nominal" };
|
||||||
@@ -113,23 +114,31 @@ export function DashboardView({
|
|||||||
value={formatNumber(s.total_messages)}
|
value={formatNumber(s.total_messages)}
|
||||||
tone="signal"
|
tone="signal"
|
||||||
icon={<MessageSquare className="size-3.5" />}
|
icon={<MessageSquare className="size-3.5" />}
|
||||||
|
className="animate-stagger"
|
||||||
|
style={staggerDelay(0)}
|
||||||
/>
|
/>
|
||||||
<MetricTile
|
<MetricTile
|
||||||
label="Flagged"
|
label="Flagged"
|
||||||
value={formatNumber(s.total_flagged)}
|
value={formatNumber(s.total_flagged)}
|
||||||
tone={s.total_flagged > 0 ? "vermilion" : "neutral"}
|
tone={s.total_flagged > 0 ? "vermilion" : "neutral"}
|
||||||
hint={`${s.today_flagged} today`}
|
hint={`${s.today_flagged} today`}
|
||||||
|
className="animate-stagger"
|
||||||
|
style={staggerDelay(1)}
|
||||||
/>
|
/>
|
||||||
<MetricTile
|
<MetricTile
|
||||||
label="Active 24h"
|
label="Active 24h"
|
||||||
value={formatNumber(s.active_users_24h)}
|
value={formatNumber(s.active_users_24h)}
|
||||||
tone="signal"
|
tone="signal"
|
||||||
icon={<Users className="size-3.5" />}
|
icon={<Users className="size-3.5" />}
|
||||||
|
className="animate-stagger"
|
||||||
|
style={staggerDelay(2)}
|
||||||
/>
|
/>
|
||||||
<MetricTile
|
<MetricTile
|
||||||
label="Voice clips"
|
label="Voice clips"
|
||||||
value={formatNumber(s.total_voice_recordings)}
|
value={formatNumber(s.total_voice_recordings)}
|
||||||
icon={<Mic className="size-3.5" />}
|
icon={<Mic className="size-3.5" />}
|
||||||
|
className="animate-stagger"
|
||||||
|
style={staggerDelay(3)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</GlassPanel>
|
</GlassPanel>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
import { formatDuration } from "@/lib/format";
|
import { formatDuration } from "@/lib/format";
|
||||||
import type { MediaState } from "@/lib/types";
|
import type { MediaState } from "@/lib/types";
|
||||||
|
import { staggerDelay } from "@/lib/utils";
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
import { useWebSocket } from "@/lib/ws/context";
|
||||||
|
|
||||||
export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
||||||
@@ -205,7 +206,8 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
|||||||
{queueList.map((item, i) => (
|
{queueList.map((item, i) => (
|
||||||
<div
|
<div
|
||||||
key={`${item.source}-${i}`}
|
key={`${item.source}-${i}`}
|
||||||
className="flex items-center gap-3 rounded-[10px] border border-hairline bg-white/5 px-3 py-2.5"
|
className="animate-stagger flex items-center gap-3 rounded-[10px] border border-hairline bg-white/5 px-3 py-2.5"
|
||||||
|
style={staggerDelay(i)}
|
||||||
>
|
>
|
||||||
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
safeParseJsonArray,
|
safeParseJsonArray,
|
||||||
} from "@/lib/format";
|
} from "@/lib/format";
|
||||||
import type { AiStatus, Guild, MessageRecord } from "@/lib/types";
|
import type { AiStatus, Guild, MessageRecord } from "@/lib/types";
|
||||||
|
import { staggerDelay } from "@/lib/utils";
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
import { useWebSocket } from "@/lib/ws/context";
|
||||||
|
|
||||||
export function MessagesView({
|
export function MessagesView({
|
||||||
@@ -254,16 +255,17 @@ export function MessagesView({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{display.map((m) => (
|
{display.map((m, i) => (
|
||||||
<button
|
<button
|
||||||
key={m.id}
|
key={m.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelected(m.id)}
|
onClick={() => setSelected(m.id)}
|
||||||
className={`flex w-full items-start gap-3 rounded-[12px] border p-3 text-left transition-colors ${
|
className={`animate-stagger flex w-full items-start gap-3 rounded-[12px] border p-3 text-left transition-colors ${
|
||||||
selected === m.id
|
selected === m.id
|
||||||
? "border-signal/40 bg-signal/8"
|
? "border-signal/40 bg-signal/8"
|
||||||
: "border-hairline bg-white/[0.03] hover:bg-white/[0.06]"
|
: "border-hairline bg-white/[0.03] hover:bg-white/[0.06]"
|
||||||
}`}
|
}`}
|
||||||
|
style={staggerDelay(i)}
|
||||||
>
|
>
|
||||||
<Avatar src={m.avatar_url} name={m.username} size={34} />
|
<Avatar src={m.avatar_url} name={m.username} size={34} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import type {
|
|||||||
ModerationActionType,
|
ModerationActionType,
|
||||||
ModerationStats,
|
ModerationStats,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
import { staggerDelay } from "@/lib/utils";
|
||||||
|
|
||||||
const ACTION_ICON: Record<ModerationActionType, React.ReactNode> = {
|
const ACTION_ICON: Record<ModerationActionType, React.ReactNode> = {
|
||||||
delete_message: <Trash2 className="size-3.5" />,
|
delete_message: <Trash2 className="size-3.5" />,
|
||||||
@@ -217,8 +218,8 @@ export function ModerationView({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1">
|
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1">
|
||||||
{(actions ?? []).map((a) => (
|
{(actions ?? []).map((a, i) => (
|
||||||
<ActionRow key={a.id} a={a} />
|
<ActionRow key={a.id} a={a} index={i} />
|
||||||
))}
|
))}
|
||||||
{(actions ?? []).length === 0 && (
|
{(actions ?? []).length === 0 && (
|
||||||
<div className="py-10 text-center text-xs text-ink-faint">
|
<div className="py-10 text-center text-xs text-ink-faint">
|
||||||
@@ -232,7 +233,7 @@ export function ModerationView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActionRow({ a }: { a: ModerationAction }) {
|
function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
|
||||||
const tone =
|
const tone =
|
||||||
a.status === "executed"
|
a.status === "executed"
|
||||||
? "signal"
|
? "signal"
|
||||||
@@ -243,7 +244,10 @@ function ActionRow({ a }: { a: ModerationAction }) {
|
|||||||
<AlertTriangle className="size-3.5" />
|
<AlertTriangle className="size-3.5" />
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3 rounded-[10px] border border-hairline bg-white/[0.03] p-3">
|
<div
|
||||||
|
className="animate-stagger flex items-start gap-3 rounded-[10px] border border-hairline bg-white/[0.03] p-3"
|
||||||
|
style={staggerDelay(index)}
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
className={`mt-0.5 ${tone === "vermilion" ? "text-vermilion" : tone === "amber" ? "text-amber" : "text-signal"}`}
|
className={`mt-0.5 ${tone === "vermilion" ? "text-vermilion" : tone === "amber" ? "text-amber" : "text-signal"}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
import { formatBytes, formatRelativeTime } from "@/lib/format";
|
import { formatBytes, formatRelativeTime } from "@/lib/format";
|
||||||
import type { VoiceRecording } from "@/lib/types";
|
import type { VoiceRecording } from "@/lib/types";
|
||||||
|
import { staggerDelay } from "@/lib/utils";
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
import { useWebSocket } from "@/lib/ws/context";
|
||||||
|
|
||||||
export function RecordingsView({
|
export function RecordingsView({
|
||||||
@@ -94,12 +95,13 @@ export function RecordingsView({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{(items ?? []).map((r) => {
|
{(items ?? []).map((r, i) => {
|
||||||
const up = uploadStatus(r);
|
const up = uploadStatus(r);
|
||||||
return (
|
return (
|
||||||
<GlassCard
|
<GlassCard
|
||||||
key={r.id}
|
key={r.id}
|
||||||
className="flex flex-col gap-3 transition-colors hover:bg-white/[0.06]"
|
className="animate-stagger flex flex-col gap-3 transition-colors hover:bg-white/[0.06]"
|
||||||
|
style={staggerDelay(i)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Avatar src={r.avatar_url} name={r.username} size={38} />
|
<Avatar src={r.avatar_url} name={r.username} size={38} />
|
||||||
|
|||||||
@@ -280,6 +280,25 @@
|
|||||||
.animate-fade-up {
|
.animate-fade-up {
|
||||||
animation: fade-up 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
|
animation: fade-up 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
}
|
}
|
||||||
|
/* List stagger: each child rises in sequence. Pair with inline
|
||||||
|
style={{ animationDelay }} set via staggerDelay(). */
|
||||||
|
.animate-stagger {
|
||||||
|
animation: fade-up 0.42s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
/* Toast: slide up + settle from the dock edge. */
|
||||||
|
.animate-toast-in {
|
||||||
|
animation: toast-in 0.22s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
@keyframes toast-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(14px) scale(0.98);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.scan-line::after,
|
.scan-line::after,
|
||||||
@@ -289,7 +308,9 @@
|
|||||||
.animate-pulse-ring,
|
.animate-pulse-ring,
|
||||||
.animate-shimmer,
|
.animate-shimmer,
|
||||||
.animate-breathe,
|
.animate-breathe,
|
||||||
.animate-fade-up {
|
.animate-fade-up,
|
||||||
|
.animate-stagger,
|
||||||
|
.animate-toast-in {
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
}
|
}
|
||||||
.scan-line {
|
.scan-line {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const Button = ({
|
|||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center whitespace-nowrap transition-all duration-150 select-none",
|
"inline-flex items-center justify-center whitespace-nowrap transition-all duration-150 select-none active:scale-[0.97]",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal/60 disabled:opacity-40 disabled:pointer-events-none",
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal/60 disabled:opacity-40 disabled:pointer-events-none",
|
||||||
variants[variant],
|
variants[variant],
|
||||||
sizes[size],
|
sizes[size],
|
||||||
|
|||||||
@@ -74,8 +74,11 @@ export function Toaster({ position = "bottom-right" }: { position?: string }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={t.id}
|
key={t.id}
|
||||||
className="glass pointer-events-auto flex items-start gap-3 p-3.5"
|
className={cn(
|
||||||
style={{ animation: "fade-up 0.18s ease" }}
|
"glass animate-toast-in pointer-events-auto flex items-start gap-3 p-3.5 transition-colors",
|
||||||
|
t.tone === "signal" && "border-signal/30",
|
||||||
|
t.tone === "vermilion" && "border-vermilion/30",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -95,11 +98,12 @@ export function Toaster({ position = "bottom-right" }: { position?: string }) {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label="Dismiss"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
store = store.filter((x) => x.id !== t.id);
|
store = store.filter((x) => x.id !== t.id);
|
||||||
emit();
|
emit();
|
||||||
}}
|
}}
|
||||||
className="text-ink-faint hover:text-ink"
|
className="rounded-md p-1 text-ink-faint transition-colors hover:bg-white/8 hover:text-ink"
|
||||||
>
|
>
|
||||||
<X className="size-3.5" />
|
<X className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function MetricTile({
|
|||||||
spark,
|
spark,
|
||||||
icon,
|
icon,
|
||||||
className,
|
className,
|
||||||
|
style,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: React.ReactNode;
|
value: React.ReactNode;
|
||||||
@@ -45,6 +46,7 @@ export function MetricTile({
|
|||||||
spark?: number[];
|
spark?: number[];
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
}) {
|
}) {
|
||||||
const toneColor =
|
const toneColor =
|
||||||
tone === "vermilion"
|
tone === "vermilion"
|
||||||
@@ -60,6 +62,7 @@ export function MetricTile({
|
|||||||
"glass p-4 ring-focus transition-transform duration-200 hover:-translate-y-0.5",
|
"glass p-4 ring-focus transition-transform duration-200 hover:-translate-y-0.5",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
style={style}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="eyebrow">{label}</div>
|
<div className="eyebrow">{label}</div>
|
||||||
|
|||||||
@@ -4,3 +4,16 @@ import { twMerge } from "tailwind-merge";
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an inline style that staggers a list item's entrance animation.
|
||||||
|
* Pair with the `animate-stagger` class. Caps the delay so long lists still
|
||||||
|
* appear promptly.
|
||||||
|
*/
|
||||||
|
export function staggerDelay(
|
||||||
|
index: number,
|
||||||
|
step = 45,
|
||||||
|
max = 600,
|
||||||
|
): React.CSSProperties {
|
||||||
|
return { animationDelay: `${Math.min(index * step, max)}ms` };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user