From caee0c692a663b829b67443140c2c9b34d8e8a97 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Wed, 3 Jun 2026 03:08:36 +0700 Subject: [PATCH] feat(frontend): add ChibiMascot component with animation variants --- .../src/widgets/mascot/ChibiMascot.tsx | 653 ++++++++++++++++++ services/frontend/tailwind.config.js | 17 + 2 files changed, 670 insertions(+) create mode 100644 services/frontend/src/widgets/mascot/ChibiMascot.tsx diff --git a/services/frontend/src/widgets/mascot/ChibiMascot.tsx b/services/frontend/src/widgets/mascot/ChibiMascot.tsx new file mode 100644 index 0000000..d059b5a --- /dev/null +++ b/services/frontend/src/widgets/mascot/ChibiMascot.tsx @@ -0,0 +1,653 @@ +import { cn } from "../../shared/lib/utils"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +type MascotVariant = "idle" | "waving" | "sleeping" | "thinking" | "peeking" | "crying"; + +interface ChibiMascotProps { + variant?: MascotVariant; + size?: "sm" | "md" | "lg"; + className?: string; +} + +// ─── Size map ──────────────────────────────────────────────────────────────── + +const sizes = { sm: 48, md: 80, lg: 120 } as const; + +// ─── Keyframes ─────────────────────────────────────────────────────────────── + +const keyframes = ` +@keyframes chibi-bob { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-3px); } +} +@keyframes chibi-wave { + 0%, 100% { transform: rotate(0deg); } + 25% { transform: rotate(15deg); } + 75% { transform: rotate(-10deg); } +} +@keyframes chibi-sleep { + 0%, 100% { transform: scaleY(1); } + 50% { transform: scaleY(0.92); } +} +@keyframes chibi-think { + 0%, 100% { transform: translateY(0) rotate(0deg); } + 50% { transform: translateY(-4px) rotate(5deg); } +} +@keyframes chibi-cry { + 0%, 100% { transform: translateY(0) scale(1); } + 25% { transform: translateY(-2px) scale(1.02); } + 75% { transform: translateY(1px) scale(0.98); } +} +@keyframes chibi-peek { + 0%, 100% { transform: translateX(0) rotate(0deg); } + 50% { transform: translateX(-3px) rotate(-3deg); } +} +@keyframes chibi-teardrop { + 0% { opacity: 1; transform: translateY(0) scaleX(1); } + 100% { opacity: 0; transform: translateY(12px) scaleX(0.5); } +} +@keyframes chibi-blink { + 0%, 90%, 100% { transform: scaleY(1); } + 95% { transform: scaleY(0.1); } +} +@keyframes chibi-zzz { + 0% { opacity: 0; transform: translateX(0) translateY(0); } + 50% { opacity: 1; transform: translateX(6px) translateY(-6px); } + 100% { opacity: 0; transform: translateX(12px) translateY(-12px); } +} +@keyframes chibi-dots { + 0% { opacity: 0; } + 50% { opacity: 1; } + 100% { opacity: 0; } +} +`; + +// ─── Variant animation styles ──────────────────────────────────────────────── + +function getAnimation(variant: MascotVariant): React.CSSProperties { + const map: Record = { + idle: "chibi-bob 2.5s ease-in-out infinite", + waving: "chibi-bob 2.5s ease-in-out infinite", + sleeping: "chibi-sleep 3s ease-in-out infinite", + thinking: "chibi-think 2s ease-in-out infinite", + peeking: "chibi-peek 2s ease-in-out infinite", + crying: "chibi-cry 1.5s ease-in-out infinite", + }; + return { animation: map[variant] }; +} + +// ─── Component ─────────────────────────────────────────────────────────────── + +export function ChibiMascot({ variant = "idle", size = "md", className }: ChibiMascotProps) { + const px = sizes[size]; + const scale = px / 80; // base design is 80px + + // Parts of the chibi + const headSize = 50 * scale; + const earSize = 18 * scale; + const eyeSize = 8 * scale; + const blushSize = 10 * scale; + const bodyHeight = 30 * scale; + const bodyWidth = 40 * scale; + + // Colors + const skin = "#FFE4D6"; + const skinShadow = "#F5D5C3"; + const hair = "#FFB7C5"; + const hairDark = "#F59CB0"; + const eyeColor = "#7EC8E3"; + const eyeShine = "#FFFFFF"; + const blush = "#FFB7C5"; + const outfit = "#7EC8E3"; + const outline = "#E8C4C9"; + + const parts: React.ReactNode[] = []; + + // ─── Body ─── + const Body = ( +
+ ); + parts.push(Body); + + // ─── Arms (waving variant) ─── + if (variant === "waving") { + const Arm = ( +
+ ); + parts.push(Arm); + } + + // ─── Head ─── + const Head = ( +
+ ); + parts.push(Head); + + // ─── Cat ears ─── + const earStyle: React.CSSProperties = { + position: "absolute", + top: -earSize * 0.4, + width: 0, + height: 0, + borderLeft: `${earSize * 0.6}px solid transparent`, + borderRight: `${earSize * 0.6}px solid transparent`, + borderBottom: `${earSize}px solid ${hair}`, + zIndex: 4, + filter: `drop-shadow(0 1px 0 ${outline})`, + }; + + parts.push( +
, + ); + parts.push( +
, + ); + + // Inner ear (pink) + const innerEarStyle: React.CSSProperties = { + position: "absolute", + top: -earSize * 0.2, + width: 0, + height: 0, + borderLeft: `${earSize * 0.35}px solid transparent`, + borderRight: `${earSize * 0.35}px solid transparent`, + borderBottom: `${earSize * 0.6}px solid ${blush}`, + zIndex: 5, + }; + + parts.push( +
, + ); + parts.push( +
, + ); + + // ─── Hair bang ─── + parts.push( +
, + ); + + // ─── Eyes ─── + if (variant === "sleeping") { + // Closed eyes (lines) + parts.push( +
, + ); + parts.push( +
, + ); + } else if (variant === "crying") { + // Squeezed eyes + parts.push( +
, + ); + parts.push( +
, + ); + + // Teardrops + parts.push( +
, + ); + parts.push( +
, + ); + } else { + // Normal big eyes + const eyeStyle: React.CSSProperties = { + position: "absolute", + top: headSize * 0.38, + width: eyeSize, + height: eyeSize * 1.1, + background: eyeColor, + borderRadius: "50%", + border: `1.5px solid #555`, + zIndex: 7, + animation: "chibi-blink 4s 1s infinite", + }; + + parts.push( +
+
+
, + ); + parts.push( +
+
+
, + ); + } + + // ─── Blush ─── + const blushStyle: React.CSSProperties = { + position: "absolute", + top: headSize * 0.52, + width: blushSize, + height: blushSize * 0.6, + background: blush, + borderRadius: "50%", + opacity: 0.5, + zIndex: 7, + }; + + parts.push( +
, + ); + parts.push( +
, + ); + + // ─── Mouth ─── + if (variant === "crying") { + parts.push( +
, + ); + } else { + parts.push( +
, + ); + } + + // ─── Variant extras ─── + + // Thinking dots + if (variant === "thinking") { + parts.push( +
, + ); + parts.push( +
, + ); + parts.push( +
, + ); + } + + // Sleeping zzz + if (variant === "sleeping") { + parts.push( +
+ z +
, + ); + parts.push( +
+ z +
, + ); + parts.push( +
+ Z +
, + ); + } + + return ( + <> + +
+ {parts} +
+ + ); +} + +// ─── Empty state with mascot ───────────────────────────────────────────────── + +interface EmptyStateMascotProps { + variant?: MascotVariant; + message: string; + action?: { label: string; onClick: () => void }; + className?: string; +} + +export function EmptyStateMascot({ + variant = "idle", + message, + action, + className, +}: EmptyStateMascotProps) { + return ( +
+ +

{message}

+ {action && ( + + )} +
+ ); +} diff --git a/services/frontend/tailwind.config.js b/services/frontend/tailwind.config.js index 085792f..3ce7f27 100644 --- a/services/frontend/tailwind.config.js +++ b/services/frontend/tailwind.config.js @@ -9,6 +9,9 @@ export default { ring: "hsl(var(--ring))", background: "hsl(var(--background))", foreground: "hsl(var(--foreground))", + "primary-soft": "hsl(var(--primary-soft))", + "primary-glow": "hsl(var(--primary-glow))", + "accent-glow": "hsl(var(--accent-glow))", primary: { DEFAULT: "hsl(var(--primary))", foreground: "hsl(var(--primary-foreground))" }, secondary: { DEFAULT: "hsl(var(--secondary))", foreground: "hsl(var(--secondary-foreground))" }, muted: { DEFAULT: "hsl(var(--muted))", foreground: "hsl(var(--muted-foreground))" }, @@ -21,6 +24,20 @@ export default { md: "calc(var(--radius) - 2px)", sm: "calc(var(--radius) - 4px)", }, + animation: { + shimmer: "shimmer 2s linear infinite", + "bar-pulse": "bar-pulse 0.4s ease-in-out infinite", + }, + keyframes: { + shimmer: { + "0%": { backgroundPosition: "-200% 0" }, + "100%": { backgroundPosition: "200% 0" }, + }, + "bar-pulse": { + "0%, 100%": { transform: "scaleY(0.8)" }, + "50%": { transform: "scaleY(1.2)" }, + }, + }, }, }, plugins: [],