feat: Sprint 3 — delight, polish, and responsive sidebar

Celebration micro-interactions:
- SuccessToast: auto-dismissing pill toast (success/info/warning variants)
- ConfettiBurst: pure CSS 20-particle confetti on project creation
- Project wizard: confetti + toast on successful creation
- Vacation approval/rejection: contextual toasts
- Allocation status change: success toast
- Button: active:scale-[0.97] press feedback on all variants

Collapsible sidebar + responsive:
- Desktop: toggle collapse (72px icons-only mode) with localStorage persistence
- NavTooltip: hover labels on collapsed icons
- Mobile: hamburger menu + slide-in overlay with backdrop
- Auto-close sidebar on mobile navigation
- Scroll-to-top on route change (smooth behavior)

Hover polish + accessibility:
- Table rows: animated left-border accent + hover-lift
- Stat cards + widgets: hover elevation + border glow
- Timeline blocks: scale(1.02) + shadow-md on hover
- Smooth scroll globally with prefers-reduced-motion fallback
- Filter chips: framer-motion scale+fade enter/exit
- Dropdowns: scaleY origin-top reveal animation
- Preferences modal: scale+fade entrance
- Link underline: animated ::after width expansion on hover

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-19 01:02:51 +01:00
parent a97597093f
commit f1f1be21c7
15 changed files with 804 additions and 177 deletions
@@ -0,0 +1,105 @@
"use client";
import { useEffect, useRef, useCallback } from "react";
interface ConfettiBurstProps {
trigger: boolean;
particleCount?: number;
duration?: number;
colors?: string[];
}
const DEFAULT_COLORS = [
"#6366f1", // indigo
"#8b5cf6", // violet
"#ec4899", // pink
"#f59e0b", // amber
"#10b981", // emerald
"#3b82f6", // blue
];
const KEYFRAMES_ID = "confetti-burst-keyframes";
function ensureKeyframes() {
if (typeof document === "undefined") return;
if (document.getElementById(KEYFRAMES_ID)) return;
const style = document.createElement("style");
style.id = KEYFRAMES_ID;
style.textContent = `
@keyframes confetti-burst {
0% {
transform: translate(0, 0) rotate(0deg) scale(1);
opacity: 1;
}
100% {
transform: translate(var(--confetti-x), var(--confetti-y)) rotate(var(--confetti-r)) scale(0.3);
opacity: 0;
}
}
`;
document.head.appendChild(style);
}
export function ConfettiBurst({
trigger,
particleCount = 20,
duration = 800,
colors = DEFAULT_COLORS,
}: ConfettiBurstProps) {
const containerRef = useRef<HTMLDivElement>(null);
const prevTrigger = useRef(false);
const burst = useCallback(() => {
const container = containerRef.current;
if (!container) return;
ensureKeyframes();
const particles: HTMLDivElement[] = [];
for (let i = 0; i < particleCount; i++) {
const el = document.createElement("div");
const angle = (Math.PI * 2 * i) / particleCount + (Math.random() - 0.5) * 0.5;
const distance = 40 + Math.random() * 80;
const x = Math.cos(angle) * distance;
const y = Math.sin(angle) * distance - 20 + Math.random() * 60; // gravity bias
const rotation = Math.random() * 720 - 360;
el.style.cssText = `
position: absolute;
left: 50%;
top: 50%;
width: 6px;
height: 6px;
margin: -3px 0 0 -3px;
border-radius: ${Math.random() > 0.5 ? "50%" : "1px"};
background: ${colors[i % colors.length]};
pointer-events: none;
--confetti-x: ${x}px;
--confetti-y: ${y}px;
--confetti-r: ${rotation}deg;
animation: confetti-burst ${duration}ms cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
`;
container.appendChild(el);
particles.push(el);
}
setTimeout(() => {
particles.forEach((el) => el.remove());
}, duration + 50);
}, [particleCount, duration, colors]);
useEffect(() => {
if (trigger && !prevTrigger.current) {
burst();
}
prevTrigger.current = trigger;
}, [trigger, burst]);
return (
<div
ref={containerRef}
className="pointer-events-none absolute inset-0 overflow-visible"
aria-hidden="true"
/>
);
}
+33 -15
View File
@@ -1,3 +1,7 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
export interface Chip {
label: string;
onRemove: () => void;
@@ -8,27 +12,41 @@ interface FilterChipsProps {
onClearAll: () => void;
}
const chipVariants = {
initial: { opacity: 0, scale: 0.8 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.8 },
};
export function FilterChips({ chips, onClearAll }: FilterChipsProps) {
if (chips.length === 0) return null;
return (
<div className="flex flex-wrap items-center gap-2">
{chips.map((chip) => (
<span
key={chip.label}
className="inline-flex items-center gap-1 rounded-full bg-brand-50 text-brand-700 border border-brand-200 px-2.5 py-0.5 text-xs"
>
{chip.label}
<button
type="button"
onClick={chip.onRemove}
className="ml-0.5 hover:text-brand-900 transition-colors"
aria-label={`Remove filter: ${chip.label}`}
<AnimatePresence mode="popLayout">
{chips.map((chip) => (
<motion.span
key={chip.label}
variants={chipVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.15, ease: "easeOut" }}
layout
className="inline-flex items-center gap-1 rounded-full bg-brand-50 text-brand-700 border border-brand-200 px-2.5 py-0.5 text-xs"
>
×
</button>
</span>
))}
{chip.label}
<button
type="button"
onClick={chip.onRemove}
className="ml-0.5 hover:text-brand-900 transition-colors"
aria-label={`Remove filter: ${chip.label}`}
>
×
</button>
</motion.span>
))}
</AnimatePresence>
<button
type="button"
onClick={onClearAll}
@@ -0,0 +1,75 @@
"use client";
import { useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
interface SuccessToastProps {
show: boolean;
message: string;
onDone?: () => void;
variant?: "success" | "info" | "warning";
}
const VARIANT_STYLES = {
success: {
bg: "bg-emerald-50 dark:bg-emerald-950/80 border-emerald-200 dark:border-emerald-800",
text: "text-emerald-800 dark:text-emerald-200",
icon: (
<svg className="h-4 w-4 text-emerald-500 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
),
},
info: {
bg: "bg-blue-50 dark:bg-blue-950/80 border-blue-200 dark:border-blue-800",
text: "text-blue-800 dark:text-blue-200",
icon: (
<svg className="h-4 w-4 text-blue-500 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<circle cx="12" cy="12" r="10" strokeWidth={2} />
<path strokeLinecap="round" d="M12 16v-4M12 8h.01" />
</svg>
),
},
warning: {
bg: "bg-amber-50 dark:bg-amber-950/80 border-amber-200 dark:border-amber-800",
text: "text-amber-800 dark:text-amber-200",
icon: (
<svg className="h-4 w-4 text-amber-500 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
),
},
} as const;
export function SuccessToast({ show, message, onDone, variant = "success" }: SuccessToastProps) {
const style = VARIANT_STYLES[variant];
useEffect(() => {
if (!show) return;
const timer = setTimeout(() => {
onDone?.();
}, 2500);
return () => clearTimeout(timer);
}, [show, onDone]);
return (
<AnimatePresence>
{show && (
<motion.div
initial={{ y: -40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -40, opacity: 0 }}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
className="fixed top-4 left-1/2 z-[9999] -translate-x-1/2"
>
<div
className={`flex items-center gap-2 rounded-full border px-4 py-2 shadow-lg backdrop-blur-sm ${style.bg}`}
>
{style.icon}
<span className={`text-sm font-medium ${style.text}`}>{message}</span>
</div>
</motion.div>
)}
</AnimatePresence>
);
}