feat: Sprint 1 — Alive Enterprise animation foundation
Animation primitives (6 new components): - AnimatedNumber: count-up with easeOutExpo, de-DE locale formatting - ShimmerSkeleton: diagonal gradient sweep replacing animate-pulse - FadeIn: framer-motion viewport-triggered fade + slide - StaggerList/StaggerItem: staggered children entrance - Sparkline: pure SVG inline trend chart with draw-in animation - ProgressRing: animated circular progress with CSS transitions Sidebar & page transitions: - Sliding nav indicator (framer-motion layoutId animation) - Icon frame hover glow (brand-color shadow) - Smooth section collapse/expand (AnimatePresence height animation) - PageTransition wrapper (fade-up on route change) - AnimatedModal component (scale + fade with custom bezier) - Notification badge bounce on count increase Dashboard animations: - StatCards: AnimatedNumber count-up + staggered FadeIn + budget color tinting - WidgetContainer: fade-slide-up on mount - Chargeability: animated percentages + inline utilization bars - ProjectTable/MyProjects: animated numbers + staggered row entrance Shimmer skeletons & table animations: - Replaced animate-pulse across 20+ loading states with shimmer gradient - Staggered row entrance (fadeSlideIn) on Resources, Projects, Allocations tables - hover-lift utility class for subtle card/row elevation on hover - Content-shaped skeletons (avatars, text bars, badges) Light mode surface depth: - Mesh gradient page background (subtle accent-tinted corners) - Enhanced card shadows (two-layer depth) - Sidebar glassmorphism upgrade (bg-white/60, backdrop-blur-2xl, saturate-150) - Toolbar sticky backdrop blur - Enhanced focus ring with brand-color glow Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
|
||||
interface SparklineProps {
|
||||
data: number[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
color?: string;
|
||||
fillOpacity?: number;
|
||||
strokeWidth?: number;
|
||||
className?: string;
|
||||
animated?: boolean;
|
||||
}
|
||||
|
||||
function buildPoints(
|
||||
data: number[],
|
||||
width: number,
|
||||
height: number,
|
||||
strokeWidth: number,
|
||||
): string {
|
||||
if (data.length === 0) return "";
|
||||
|
||||
const padding = strokeWidth;
|
||||
const drawWidth = width - padding * 2;
|
||||
const drawHeight = height - padding * 2;
|
||||
|
||||
const min = Math.min(...data);
|
||||
const max = Math.max(...data);
|
||||
const range = max - min || 1; // avoid division by zero when all values equal
|
||||
|
||||
return data
|
||||
.map((v, i) => {
|
||||
const x =
|
||||
data.length === 1
|
||||
? width / 2
|
||||
: padding + (i / (data.length - 1)) * drawWidth;
|
||||
const y = padding + drawHeight - ((v - min) / range) * drawHeight;
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function buildAreaPoints(
|
||||
linePoints: string,
|
||||
width: number,
|
||||
height: number,
|
||||
strokeWidth: number,
|
||||
dataLength: number,
|
||||
): string {
|
||||
if (!linePoints) return "";
|
||||
|
||||
const padding = strokeWidth;
|
||||
const drawWidth = width - padding * 2;
|
||||
const bottom = height - padding;
|
||||
|
||||
const lastX =
|
||||
dataLength === 1 ? width / 2 : padding + drawWidth;
|
||||
const firstX = dataLength === 1 ? width / 2 : padding;
|
||||
|
||||
return `${linePoints} ${lastX},${bottom} ${firstX},${bottom}`;
|
||||
}
|
||||
|
||||
export function Sparkline({
|
||||
data,
|
||||
width = 60,
|
||||
height = 20,
|
||||
color = "currentColor",
|
||||
fillOpacity = 0.1,
|
||||
strokeWidth = 1.5,
|
||||
className,
|
||||
animated = true,
|
||||
}: SparklineProps) {
|
||||
const id = useId();
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className={className}
|
||||
role="img"
|
||||
aria-label="No data"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const linePoints = buildPoints(data, width, height, strokeWidth);
|
||||
const areaPoints = buildAreaPoints(
|
||||
linePoints,
|
||||
width,
|
||||
height,
|
||||
strokeWidth,
|
||||
data.length,
|
||||
);
|
||||
|
||||
// Approximate path length for dash animation
|
||||
const approxLength = width * 2;
|
||||
|
||||
return (
|
||||
<>
|
||||
{animated && (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@keyframes sparkline-draw-${CSS.escape(id)} {
|
||||
from { stroke-dashoffset: ${approxLength}; }
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className={className}
|
||||
role="img"
|
||||
aria-label={`Sparkline: ${data.join(", ")}`}
|
||||
>
|
||||
{/* Area fill */}
|
||||
{data.length > 1 && (
|
||||
<polygon
|
||||
points={areaPoints}
|
||||
fill={color}
|
||||
opacity={fillOpacity}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Line */}
|
||||
{data.length === 1 ? (
|
||||
<circle
|
||||
cx={width / 2}
|
||||
cy={height / 2}
|
||||
r={strokeWidth}
|
||||
fill={color}
|
||||
/>
|
||||
) : (
|
||||
<polyline
|
||||
points={linePoints}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...(animated
|
||||
? {
|
||||
strokeDasharray: approxLength,
|
||||
strokeDashoffset: approxLength,
|
||||
style: {
|
||||
animation: `sparkline-draw-${CSS.escape(id)} 600ms ease-out forwards`,
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user