feat(frontend): staggered list reveals + toast tone accents (level 5 polish)
This commit is contained in:
@@ -32,6 +32,7 @@ import { useActivity, useStats, useTopReactions } from "@/hooks";
|
|||||||
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
|
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
|
||||||
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)
|
if (!stats)
|
||||||
@@ -334,7 +335,8 @@ export function DashboardView({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={ch.channel_id}
|
key={ch.channel_id}
|
||||||
className="flex items-center gap-3 text-xs"
|
className="animate-stagger flex items-center gap-3 text-xs"
|
||||||
|
style={staggerDelay(i)}
|
||||||
>
|
>
|
||||||
<span className="font-mono w-4 text-[10px] text-ink-faint">
|
<span className="font-mono w-4 text-[10px] text-ink-faint">
|
||||||
{String(i + 1).padStart(2, "0")}
|
{String(i + 1).padStart(2, "0")}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import type {
|
|||||||
MessageMetadata,
|
MessageMetadata,
|
||||||
MessageRecord,
|
MessageRecord,
|
||||||
} from "@/lib/types";
|
} 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({
|
||||||
@@ -354,7 +355,8 @@ export function MessagesView({
|
|||||||
{semantic.data.map((r, i) => (
|
{semantic.data.map((r, i) => (
|
||||||
<div
|
<div
|
||||||
key={r.message_id ?? i}
|
key={r.message_id ?? i}
|
||||||
className="hud-card flex items-start gap-3 p-3"
|
className="hud-card animate-stagger flex items-start gap-3 p-3"
|
||||||
|
style={staggerDelay(i)}
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -473,7 +475,7 @@ export function MessagesView({
|
|||||||
>
|
>
|
||||||
<div ref={streamRef} className="space-y-1.5">
|
<div ref={streamRef} className="space-y-1.5">
|
||||||
{viewMode === "timeline" && timelineNodes
|
{viewMode === "timeline" && timelineNodes
|
||||||
? timelineNodes.map((node) =>
|
? timelineNodes.map((node, i) =>
|
||||||
node.type === "date" ? (
|
node.type === "date" ? (
|
||||||
<div
|
<div
|
||||||
key={`date-${node.iso}`}
|
key={`date-${node.iso}`}
|
||||||
@@ -486,15 +488,17 @@ export function MessagesView({
|
|||||||
<MessageRow
|
<MessageRow
|
||||||
key={node.m.id}
|
key={node.m.id}
|
||||||
m={node.m}
|
m={node.m}
|
||||||
|
index={i}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
onSelect={setSelected}
|
onSelect={setSelected}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: display.map((m) => (
|
: display.map((m, i) => (
|
||||||
<MessageRow
|
<MessageRow
|
||||||
key={m.id}
|
key={m.id}
|
||||||
m={m}
|
m={m}
|
||||||
|
index={i}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
onSelect={setSelected}
|
onSelect={setSelected}
|
||||||
/>
|
/>
|
||||||
@@ -879,10 +883,12 @@ function MessageRow({
|
|||||||
m,
|
m,
|
||||||
selected,
|
selected,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
index = 0,
|
||||||
}: {
|
}: {
|
||||||
m: MessageRecord;
|
m: MessageRecord;
|
||||||
selected: string | null;
|
selected: string | null;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
|
index?: number;
|
||||||
}) {
|
}) {
|
||||||
const meta = parseMeta(m.metadata);
|
const meta = parseMeta(m.metadata);
|
||||||
const attachments = meta?.attachments ?? [];
|
const attachments = meta?.attachments ?? [];
|
||||||
@@ -907,11 +913,12 @@ function MessageRow({
|
|||||||
key={m.id}
|
key={m.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(m.id)}
|
onClick={() => onSelect(m.id)}
|
||||||
className={`msg-feed-card flex w-full items-start gap-3 rounded-[8px] border p-2.5 text-left transition-all ${
|
className={`msg-feed-card animate-stagger flex w-full items-start gap-3 rounded-[8px] border p-2.5 text-left transition-all ${
|
||||||
selected === m.id
|
selected === m.id
|
||||||
? "border-signal/50 bg-signal/10 shadow-xs"
|
? "border-signal/50 bg-signal/10 shadow-xs"
|
||||||
: "border-hairline bg-surface-2 hover:border-hairline-focus hover:bg-surface"
|
: "border-hairline bg-surface-2 hover:border-hairline-focus hover:bg-surface"
|
||||||
}`}
|
}`}
|
||||||
|
style={staggerDelay(index)}
|
||||||
>
|
>
|
||||||
<Avatar src={m.avatar_url} name={m.server_nick ?? m.username} size={32} />
|
<Avatar src={m.avatar_url} name={m.server_nick ?? m.username} size={32} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
|||||||
@@ -644,6 +644,13 @@
|
|||||||
.animate-fade-up { animation: fade-up 0.18s cubic-bezier(0.16, 1, 0.3, 1); }
|
.animate-fade-up { animation: fade-up 0.18s cubic-bezier(0.16, 1, 0.3, 1); }
|
||||||
.animate-toast-in { animation: toast-in 0.2s cubic-bezier(0.16, 1, 0.3, 1); }
|
.animate-toast-in { animation: toast-in 0.2s cubic-bezier(0.16, 1, 0.3, 1); }
|
||||||
|
|
||||||
|
/* Staggered list-item reveal — pair with inline `animationDelay` from
|
||||||
|
staggerDelay(i) in lib/utils.ts. Fades + slides up, forwards fill so
|
||||||
|
items stay put after animating. */
|
||||||
|
.animate-stagger {
|
||||||
|
animation: fade-up 0.24s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
/* Sweeping glow line across a hero/placeholder panel. */
|
/* Sweeping glow line across a hero/placeholder panel. */
|
||||||
.scan-line {
|
.scan-line {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -690,6 +697,7 @@
|
|||||||
.animate-eq,
|
.animate-eq,
|
||||||
.animate-fade-up,
|
.animate-fade-up,
|
||||||
.animate-toast-in,
|
.animate-toast-in,
|
||||||
|
.animate-stagger,
|
||||||
.scan-line,
|
.scan-line,
|
||||||
.ambient-blob,
|
.ambient-blob,
|
||||||
.ambient-mote,
|
.ambient-mote,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useRef } from "react";
|
|||||||
import { Badge } from "@/components/primitives";
|
import { Badge } from "@/components/primitives";
|
||||||
import { formatRelativeTime } from "@/lib/format";
|
import { formatRelativeTime } from "@/lib/format";
|
||||||
import type { ModerationAction } from "@/lib/types";
|
import type { ModerationAction } from "@/lib/types";
|
||||||
|
import { staggerDelay } from "@/lib/utils";
|
||||||
|
|
||||||
const ACTION_LABEL: Record<string, string> = {
|
const ACTION_LABEL: Record<string, string> = {
|
||||||
delete_message: "Deleted",
|
delete_message: "Deleted",
|
||||||
@@ -106,12 +107,13 @@ export function LiveModerationFeed({
|
|||||||
AWAITING MODERATION DISPATCH STREAM...
|
AWAITING MODERATION DISPATCH STREAM...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
actions.map((a) => {
|
actions.map((a, i) => {
|
||||||
const tone = severityTone(a.severity);
|
const tone = severityTone(a.severity);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={a.id}
|
key={a.id}
|
||||||
className="mod-feed-item hud-card flex items-start gap-3 p-3 transition-all"
|
className="mod-feed-item hud-card animate-stagger flex items-start gap-3 p-3 transition-all"
|
||||||
|
style={staggerDelay(i)}
|
||||||
>
|
>
|
||||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-[6px] border border-hairline bg-surface-2">
|
<span className="flex size-7 shrink-0 items-center justify-center rounded-[6px] border border-hairline bg-surface-2">
|
||||||
{actionIcon(a.action_type)}
|
{actionIcon(a.action_type)}
|
||||||
|
|||||||
@@ -76,8 +76,10 @@ export function Toaster({ position = "bottom-right" }: { position?: string }) {
|
|||||||
key={t.id}
|
key={t.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"glass animate-toast-in pointer-events-auto flex items-start gap-3 p-3.5 transition-colors",
|
"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 === "signal" && "border-signal/40",
|
||||||
t.tone === "vermilion" && "border-vermilion/30",
|
t.tone === "vermilion" && "border-vermilion/40",
|
||||||
|
t.tone === "signal" &&
|
||||||
|
"shadow-[0_0_0_1px_var(--color-signal)/20]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
|
|||||||
@@ -4,3 +4,10 @@ import { twMerge } from "tailwind-merge";
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Inline `animationDelay` for staggered list reveals. Pair with the
|
||||||
|
* `animate-stagger` class on the item root. steps=45ms, capped at 600ms so
|
||||||
|
* long lists don't drag the reveal out. */
|
||||||
|
export function staggerDelay(i: number, step = 45, max = 600) {
|
||||||
|
return { animationDelay: `${Math.min(i * step, max)}ms` };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user