From bb9506b93e57746be7139d5723326d54fb5752b1 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Thu, 18 Dec 2025 13:42:29 -0600 Subject: [PATCH] feat(practice): add celebration progression banner with smooth transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds interpolation utilities and a celebration banner component that smoothly morphs between celebration and normal states over 60 seconds. 🤫 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../CelebrationProgressionBanner.stories.tsx | 728 ++++++++++++++++++ .../practice/CelebrationProgressionBanner.tsx | 650 ++++++++++++++++ apps/web/src/hooks/useCelebrationWindDown.ts | 213 +++++ apps/web/src/utils/interpolate.ts | 347 +++++++++ 4 files changed, 1938 insertions(+) create mode 100644 apps/web/src/components/practice/CelebrationProgressionBanner.stories.tsx create mode 100644 apps/web/src/components/practice/CelebrationProgressionBanner.tsx create mode 100644 apps/web/src/hooks/useCelebrationWindDown.ts create mode 100644 apps/web/src/utils/interpolate.ts diff --git a/apps/web/src/components/practice/CelebrationProgressionBanner.stories.tsx b/apps/web/src/components/practice/CelebrationProgressionBanner.stories.tsx new file mode 100644 index 00000000..60f03bae --- /dev/null +++ b/apps/web/src/components/practice/CelebrationProgressionBanner.stories.tsx @@ -0,0 +1,728 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { useEffect, useState } from 'react' +import type { ProgressionMode } from '@/lib/curriculum/session-mode' +import { css } from '../../../styled-system/css' +import { CelebrationProgressionBanner } from './CelebrationProgressionBanner' + +// ============================================================================= +// Mock Data +// ============================================================================= + +const mockProgressionMode: ProgressionMode = { + type: 'progression', + nextSkill: { skillId: 'add-5-complement-3', displayName: '+5 − 3', pKnown: 0 }, + phase: { + id: 'L1.add.+5-3.five', + levelId: 1, + operation: 'addition', + targetNumber: 2, + usesFiveComplement: true, + usesTenComplement: false, + name: 'Five-Complement Addition', + description: 'Learn to add using five-complement technique', + primarySkillId: 'add-5-complement-3', + order: 5, + }, + tutorialRequired: true, + skipCount: 0, + focusDescription: 'Learning: +5 − 3', +} + +// ============================================================================= +// Helper to clear localStorage for fresh celebrations +// ============================================================================= + +function clearCelebrationState() { + if (typeof window !== 'undefined') { + localStorage.removeItem('skill-celebration-state') + } +} + +// ============================================================================= +// Wrapper Components for Stories +// ============================================================================= + +interface StoryWrapperProps { + children: React.ReactNode + isDark?: boolean +} + +function StoryWrapper({ children, isDark = false }: StoryWrapperProps) { + return ( +
+ {children} +
+ ) +} + +// Interactive component with speed control +function InteractiveCelebration({ + isDark = false, + initialSpeed = 1, +}: { + isDark?: boolean + initialSpeed?: number +}) { + const [speed, setSpeed] = useState(initialSpeed) + const [key, setKey] = useState(0) + + const handleReset = () => { + clearCelebrationState() + setKey((k) => k + 1) // Force remount + } + + return ( + +
+
+ + setSpeed(Number(e.target.value))} + className={css({ flex: 1, minWidth: '100px' })} + /> + +
+

+ At {speed}x, the full 60-second transition takes {Math.round(60 / speed)} seconds +

+
+ console.log('Action clicked')} + isLoading={false} + variant="dashboard" + isDark={isDark} + speedMultiplier={speed} + disableConfetti={speed > 10} // Disable confetti at high speeds + /> +
+ ) +} + +// Static progress snapshot component +function ProgressSnapshot({ + progress, + isDark = false, + variant = 'dashboard', +}: { + progress: number + isDark?: boolean + variant?: 'dashboard' | 'modal' +}) { + return ( + +
+ + Progress: {Math.round(progress * 100)}% + + + ( + {progress === 0 ? 'Full celebration' : progress === 1 ? 'Normal banner' : 'Transitioning'} + ) + +
+ console.log('Action clicked')} + isLoading={false} + variant={variant} + isDark={isDark} + forceProgress={progress} + disableConfetti + /> +
+ ) +} + +// All progress states in a grid +function ProgressGrid({ isDark = false }: { isDark?: boolean }) { + const progressSteps = [0, 0.1, 0.25, 0.5, 0.75, 0.9, 1] + + return ( +
+ {progressSteps.map((progress) => ( + + ))} +
+ ) +} + +// ============================================================================= +// Meta +// ============================================================================= + +const meta: Meta = { + title: 'Practice/CelebrationProgressionBanner', + component: CelebrationProgressionBanner, + parameters: { + layout: 'fullscreen', + }, + tags: ['autodocs'], +} + +export default meta +type Story = StoryObj + +// ============================================================================= +// Stories +// ============================================================================= + +/** + * Interactive celebration with speed control. + * Use the slider to speed up the transition (1x = real-time, 60x = 1 second total). + * Click "Reset Celebration" to restart from the beginning. + */ +export const Interactive: Story = { + render: () => , +} + +/** + * Same as Interactive but in dark mode + */ +export const InteractiveDark: Story = { + render: () => , +} + +/** + * Full celebration state (progress = 0%) + * This is what shows immediately after unlocking a skill. + */ +export const FullCelebration: Story = { + render: () => , +} + +/** + * 25% through the transition + */ +export const Progress25: Story = { + render: () => , +} + +/** + * Halfway through the transition (50%) + */ +export const Progress50: Story = { + render: () => , +} + +/** + * 75% through the transition + */ +export const Progress75: Story = { + render: () => , +} + +/** + * Fully transitioned to normal banner (progress = 100%) + */ +export const FullyNormal: Story = { + render: () => , +} + +/** + * All progress states side by side for comparison + */ +export const ProgressComparison: Story = { + render: () => , +} + +/** + * All progress states in dark mode + */ +export const ProgressComparisonDark: Story = { + render: () => , +} + +/** + * Modal variant at full celebration + */ +export const ModalVariantCelebration: Story = { + render: () => , +} + +/** + * Modal variant at 50% progress + */ +export const ModalVariantMidway: Story = { + render: () => , +} + +/** + * Modal variant fully transitioned + */ +export const ModalVariantNormal: Story = { + render: () => , +} + +/** + * Dark mode full celebration + */ +export const DarkModeCelebration: Story = { + render: () => , +} + +/** + * Dark mode 50% progress + */ +export const DarkModeMidway: Story = { + render: () => , +} + +/** + * Dark mode fully normal + */ +export const DarkModeNormal: Story = { + render: () => , +} + +/** + * Tiny increments to see the subtle early changes + */ +export const SubtleEarlyTransition: Story = { + render: () => { + const earlySteps = [0, 0.02, 0.05, 0.08, 0.1, 0.15] + return ( +
+

+ Early transition (0-15%) - Should be nearly imperceptible +

+
+ {earlySteps.map((progress) => ( + + ))} +
+
+ ) + }, +} + +/** + * Late transition to see the rapid changes at the end + */ +export const RapidLateTransition: Story = { + render: () => { + const lateSteps = [0.7, 0.8, 0.85, 0.9, 0.95, 1] + return ( +
+

+ Late transition (70-100%) - More noticeable changes +

+
+ {lateSteps.map((progress) => ( + + ))} +
+
+ ) + }, +} + +/** + * Test with confetti enabled (will fire once on load) + */ +export const WithConfetti: Story = { + render: () => { + // Clear state on mount to ensure confetti fires + useEffect(() => { + clearCelebrationState() + }, []) + + return ( + +

+ Confetti should fire once when this story loads +

+ console.log('Action clicked')} + isLoading={false} + variant="dashboard" + isDark={false} + speedMultiplier={30} // Fast transition to see the full effect quickly + /> +
+ ) + }, +} + +// ============================================================================= +// Progress Scrubber Story +// ============================================================================= + +const PROGRESS_LABELS: { value: number; label: string; description: string }[] = [ + { + value: 0, + label: '0%', + description: 'Full celebration - golden glow, large emoji, wide button margins', + }, + { value: 0.05, label: '5%', description: 'Barely perceptible change' }, + { value: 0.1, label: '10%', description: 'Still very celebratory' }, + { value: 0.2, label: '20%', description: 'Subtle reduction in glow, button expanding' }, + { value: 0.3, label: '30%', description: 'Emoji shrinking, colors shifting' }, + { value: 0.4, label: '40%', description: 'Colors shifting toward green' }, + { value: 0.5, label: '50%', description: 'Halfway - moderate size and glow' }, + { value: 0.6, label: '60%', description: 'Wiggle diminishing' }, + { value: 0.7, label: '70%', description: 'Button nearly full width' }, + { value: 0.8, label: '80%', description: 'Mostly normal appearance' }, + { value: 0.9, label: '90%', description: 'Nearly complete' }, + { value: 0.95, label: '95%', description: 'Final touches' }, + { value: 1, label: '100%', description: 'Fully normal banner' }, +] + +function ProgressScrubber({ isDark = false }: { isDark?: boolean }) { + const [progress, setProgress] = useState(0) + + // Find the closest label for current progress + const closestLabel = PROGRESS_LABELS.reduce((prev, curr) => + Math.abs(curr.value - progress) < Math.abs(prev.value - progress) ? curr : prev + ) + + // Find exact match or null + const exactLabel = PROGRESS_LABELS.find((l) => Math.abs(l.value - progress) < 0.001) + + return ( +
+ {/* Header */} +
+

+ Progress Scrubber +

+

+ Drag the slider to see the transition at any point +

+
+ + {/* Current progress display */} +
+
+ {(progress * 100).toFixed(1)}% +
+
+ {exactLabel?.description || closestLabel.description} +
+
+ + {/* Slider */} +
+ setProgress(Number(e.target.value) / 100)} + className={css({ + width: '100%', + height: '8px', + borderRadius: '4px', + cursor: 'pointer', + })} + /> + + {/* Tick marks with labels */} +
+ {PROGRESS_LABELS.filter((l) => [0, 0.25, 0.5, 0.7, 1].includes(l.value)).map((label) => ( + + ))} +
+
+ + {/* Preset buttons */} +
+ {PROGRESS_LABELS.map((label) => ( + + ))} +
+ + {/* Banner preview */} +
+ console.log('Action clicked')} + isLoading={false} + variant="dashboard" + isDark={isDark} + forceProgress={progress} + disableConfetti + /> +
+ + {/* Properties being interpolated at this progress */} +
+
+ Key values at {(progress * 100).toFixed(0)}%: +
+
+ Emoji size: + + {Math.round(64 - 32 * progress)}px + + Emoji wiggle: + + ±{(3 * (1 - progress)).toFixed(1)}° + + Title size: + {Math.round(28 - 12 * progress)}px + Border width: + {(3 - progress).toFixed(1)}px + Button margin X: + {Math.round(80 - 80 * progress)}px + Button radius: + {Math.round(12 - 12 * progress)}px + Shimmer: + {progress < 0.5 ? 'visible' : progress < 1 ? 'fading' : 'hidden'} +
+
+
+ ) +} + +/** + * Interactive progress scrubber with labeled positions. + * Click preset buttons or drag slider to jump to specific progress values. + * Use the percentage labels when discussing specific transition states. + */ +export const Scrubber: Story = { + render: () => , +} + +/** + * Same scrubber in dark mode + */ +export const ScrubberDark: Story = { + render: () => , +} diff --git a/apps/web/src/components/practice/CelebrationProgressionBanner.tsx b/apps/web/src/components/practice/CelebrationProgressionBanner.tsx new file mode 100644 index 00000000..b191705c --- /dev/null +++ b/apps/web/src/components/practice/CelebrationProgressionBanner.tsx @@ -0,0 +1,650 @@ +/** + * CelebrationProgressionBanner + * + * A special version of ProgressionBanner that displays a celebration when + * a student unlocks a new skill, then imperceptibly morphs into the normal + * banner over ~60 seconds. + * + * This component interpolates 35+ CSS properties individually for a smooth, + * magical transition that the student won't notice happening. + */ + +'use client' + +import type { Shape } from 'canvas-confetti' +import confetti from 'canvas-confetti' +import { useEffect, useRef } from 'react' +import type { ProgressionMode } from '@/lib/curriculum/session-mode' +import { + boxShadow, + boxShadowsToCss, + gradientStop, + gradientToCss, + lerp, + lerpBoxShadows, + lerpColor, + lerpGradientStops, + lerpRgbaString, + type BoxShadow, + type GradientStop, + type RGBA, +} from '@/utils/interpolate' +import { useCelebrationWindDown } from '@/hooks/useCelebrationWindDown' + +// ============================================================================= +// Types +// ============================================================================= + +interface CelebrationProgressionBannerProps { + mode: ProgressionMode + onAction: () => void + isLoading: boolean + variant: 'dashboard' | 'modal' + isDark: boolean + /** Speed multiplier for testing (1 = normal, 10 = 10x faster) */ + speedMultiplier?: number + /** Force a specific progress value (0-1) for Storybook */ + forceProgress?: number + /** Disable confetti for Storybook (to avoid spam) */ + disableConfetti?: boolean +} + +// ============================================================================= +// Confetti Celebration +// ============================================================================= + +function fireConfettiCelebration(): void { + const duration = 4000 + const animationEnd = Date.now() + duration + const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 10000 } + + function randomInRange(min: number, max: number) { + return Math.random() * (max - min) + min + } + + // Multiple bursts of confetti + const interval = setInterval(() => { + const timeLeft = animationEnd - Date.now() + + if (timeLeft <= 0) { + clearInterval(interval) + return + } + + const particleCount = 50 * (timeLeft / duration) + + // Confetti from left side + confetti({ + ...defaults, + particleCount, + origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 }, + colors: ['#FFD700', '#FFA500', '#FF6347', '#FF1493', '#00CED1', '#32CD32'], + }) + + // Confetti from right side + confetti({ + ...defaults, + particleCount, + origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 }, + colors: ['#FFD700', '#FFA500', '#FF6347', '#FF1493', '#00CED1', '#32CD32'], + }) + }, 250) + + // Initial big burst from center + confetti({ + particleCount: 100, + spread: 70, + origin: { x: 0.5, y: 0.5 }, + colors: ['#FFD700', '#FFA500', '#FF6347'], + zIndex: 10000, + }) + + // Fireworks effect - shooting stars + setTimeout(() => { + confetti({ + particleCount: 50, + angle: 60, + spread: 55, + origin: { x: 0, y: 0.8 }, + colors: ['#FFD700', '#FFFF00', '#FFA500'], + zIndex: 10000, + }) + confetti({ + particleCount: 50, + angle: 120, + spread: 55, + origin: { x: 1, y: 0.8 }, + colors: ['#FFD700', '#FFFF00', '#FFA500'], + zIndex: 10000, + }) + }, 500) + + // More fireworks + setTimeout(() => { + confetti({ + particleCount: 80, + angle: 90, + spread: 100, + origin: { x: 0.5, y: 0.9 }, + colors: ['#FF1493', '#FF69B4', '#FFB6C1', '#FF6347'], + zIndex: 10000, + }) + }, 1000) + + // Star burst finale + setTimeout(() => { + const shapes: Shape[] = ['star', 'circle'] + confetti({ + particleCount: 150, + spread: 180, + origin: { x: 0.5, y: 0.4 }, + colors: ['#FFD700', '#FFA500', '#FF6347', '#FF1493', '#00CED1', '#9370DB'], + shapes, + scalar: 1.2, + zIndex: 10000, + }) + }, 1500) +} + +// ============================================================================= +// Style Definitions - Start (Celebration) and End (Normal) States +// ============================================================================= + +// Helper to create RGBA +const rgba = (r: number, g: number, b: number, a: number): RGBA => ({ r, g, b, a }) + +// --- Container Background Gradients --- +const CELEBRATION_BG_LIGHT: GradientStop[] = [ + gradientStop(234, 179, 8, 0.2, 0), // yellow.500 @ 20% + gradientStop(251, 191, 36, 0.1, 50), // yellow.400 @ 10% + gradientStop(234, 179, 8, 0.2, 100), +] +const CELEBRATION_BG_DARK: GradientStop[] = [ + gradientStop(234, 179, 8, 0.25, 0), + gradientStop(251, 191, 36, 0.15, 50), + gradientStop(234, 179, 8, 0.25, 100), +] + +const NORMAL_BG_LIGHT: GradientStop[] = [ + gradientStop(34, 197, 94, 0.06, 0), // green.500 @ 6% + gradientStop(59, 130, 246, 0.04, 100), // blue.500 @ 4% +] +const NORMAL_BG_DARK: GradientStop[] = [ + gradientStop(34, 197, 94, 0.12, 0), + gradientStop(59, 130, 246, 0.08, 100), +] + +// Pad to same length (3 stops each) +const NORMAL_BG_LIGHT_PADDED: GradientStop[] = [ + ...NORMAL_BG_LIGHT.slice(0, 1), + gradientStop(46, 163, 170, 0.05, 50), // midpoint interpolation + ...NORMAL_BG_LIGHT.slice(1), +] +const NORMAL_BG_DARK_PADDED: GradientStop[] = [ + ...NORMAL_BG_DARK.slice(0, 1), + gradientStop(46, 163, 170, 0.1, 50), + ...NORMAL_BG_DARK.slice(1), +] + +// --- Container Box Shadows --- +const CELEBRATION_SHADOWS: BoxShadow[] = [ + boxShadow(0, 0, 20, 0, 234, 179, 8, 0.4), // glow 1 + boxShadow(0, 0, 40, 0, 234, 179, 8, 0.2), // glow 2 +] +const NORMAL_SHADOWS: BoxShadow[] = [ + boxShadow(0, 2, 8, 0, 0, 0, 0, 0.1), // subtle shadow + boxShadow(0, 0, 0, 0, 0, 0, 0, 0), // transparent (padding) +] + +// --- Button Background Gradients --- +const CELEBRATION_BTN_LIGHT: GradientStop[] = [ + gradientStop(252, 211, 77, 1, 0), // yellow.300 + gradientStop(245, 158, 11, 1, 100), // yellow.500 +] +const CELEBRATION_BTN_DARK: GradientStop[] = [ + gradientStop(252, 211, 77, 1, 0), + gradientStop(245, 158, 11, 1, 100), +] + +const NORMAL_BTN: GradientStop[] = [ + gradientStop(34, 197, 94, 1, 0), // green.500 + gradientStop(22, 163, 74, 1, 100), // green.600 +] + +// --- Button Box Shadows --- +const CELEBRATION_BTN_SHADOW: BoxShadow[] = [boxShadow(0, 4, 15, 0, 245, 158, 11, 0.4)] +const NORMAL_BTN_SHADOW: BoxShadow[] = [boxShadow(0, 2, 4, 0, 0, 0, 0, 0.1)] + +// --- Colors --- +const COLORS = { + // Border + celebrationBorder: '#eab308', // yellow.500 + normalBorderLight: '#4ade80', // green.400 + normalBorderDark: '#22c55e', // green.500 + + // Title + celebrationTitleLight: '#a16207', // yellow.700 + celebrationTitleDark: '#fef08a', // yellow.200 + normalTitleLight: '#166534', // green.800 + normalTitleDark: '#86efac', // green.300 + + // Subtitle + celebrationSubtitleLight: '#374151', // gray.700 + celebrationSubtitleDark: '#e5e7eb', // gray.200 + normalSubtitleLight: '#6b7280', // gray.500 + normalSubtitleDark: '#a1a1aa', // gray.400 + + // Button text + celebrationBtnTextLight: '#111827', // gray.900 (dark text on yellow) + celebrationBtnTextDark: '#111827', + normalBtnText: '#ffffff', // white +} + +// ============================================================================= +// Style Calculation +// ============================================================================= + +interface InterpolatedStyles { + // Container + containerBackground: string + containerBorderWidth: number + containerBorderColor: string + containerBorderRadius: number + containerPadding: number + containerBoxShadow: string + + // Emoji + emojiSize: number + emojiRotation: number + + // Title + titleFontSize: number + titleColor: string + titleTextShadow: string + + // Subtitle + subtitleFontSize: number + subtitleColor: string + subtitleMarginTop: number + + // Button + buttonPaddingY: number + buttonPaddingX: number + buttonFontSize: number + buttonBackground: string + buttonBorderRadius: number + buttonBoxShadow: string + buttonColor: string + + // Shimmer + shimmerOpacity: number + + // Layout - all interpolated, no discrete jumps + contentGap: number + contentJustify: number // 0 = center, 1 = flex-start (use transform) + textMarginLeft: number + buttonWrapperPaddingX: number + buttonWrapperPaddingBottom: number +} + +function calculateStyles( + progress: number, + oscillation: number, + isDark: boolean, + variant: 'dashboard' | 'modal' +): InterpolatedStyles { + const t = progress // 0 = celebration, 1 = normal + + // Get theme-appropriate values + const celebrationBg = isDark ? CELEBRATION_BG_DARK : CELEBRATION_BG_LIGHT + const normalBg = isDark ? NORMAL_BG_DARK_PADDED : NORMAL_BG_LIGHT_PADDED + const celebrationBtn = isDark ? CELEBRATION_BTN_DARK : CELEBRATION_BTN_LIGHT + const normalBorder = isDark ? COLORS.normalBorderDark : COLORS.normalBorderLight + const celebrationTitle = isDark ? COLORS.celebrationTitleDark : COLORS.celebrationTitleLight + const normalTitle = isDark ? COLORS.normalTitleDark : COLORS.normalTitleLight + const celebrationSubtitle = isDark + ? COLORS.celebrationSubtitleDark + : COLORS.celebrationSubtitleLight + const normalSubtitle = isDark ? COLORS.normalSubtitleDark : COLORS.normalSubtitleLight + const celebrationBtnText = isDark ? COLORS.celebrationBtnTextDark : COLORS.celebrationBtnTextLight + + // Variant-specific sizes + const isModal = variant === 'modal' + const celebrationPadding = isModal ? 20 : 24 + const normalPadding = isModal ? 14 : 16 + const celebrationTitleSize = isModal ? 24 : 28 + const normalTitleSize = isModal ? 15 : 16 + const celebrationSubtitleSize = isModal ? 16 : 18 + const normalSubtitleSize = isModal ? 12 : 13 + const celebrationBtnPaddingY = isModal ? 14 : 16 + const normalBtnPaddingY = isModal ? 14 : 16 + const celebrationBtnPaddingX = isModal ? 28 : 32 + const normalBtnPaddingX = 0 // full width in normal mode + const celebrationBtnFontSize = isModal ? 16 : 18 + const normalBtnFontSize = isModal ? 16 : 17 + + // Wiggle amplitude decreases as we transition + const wiggleAmplitude = 3 * (1 - t) + const rotation = oscillation * wiggleAmplitude + + return { + // Container + containerBackground: gradientToCss(135, lerpGradientStops(celebrationBg, normalBg, t)), + containerBorderWidth: lerp(3, 2, t), + containerBorderColor: lerpColor(COLORS.celebrationBorder, normalBorder, t), + containerBorderRadius: lerp(16, isModal ? 12 : 16, t), + containerPadding: lerp(celebrationPadding, normalPadding, t), + containerBoxShadow: boxShadowsToCss(lerpBoxShadows(CELEBRATION_SHADOWS, NORMAL_SHADOWS, t)), + + // Emoji - single emoji, just size and wiggle + emojiSize: lerp(isModal ? 48 : 64, isModal ? 24 : 32, t), + emojiRotation: rotation, + + // Title + titleFontSize: lerp(celebrationTitleSize, normalTitleSize, t), + titleColor: lerpColor(celebrationTitle, normalTitle, t), + titleTextShadow: `0 0 ${lerp(20, 0, t)}px ${lerpRgbaString(rgba(234, 179, 8, 0.5), rgba(0, 0, 0, 0), t)}`, + + // Subtitle + subtitleFontSize: lerp(celebrationSubtitleSize, normalSubtitleSize, t), + subtitleColor: lerpColor(celebrationSubtitle, normalSubtitle, t), + subtitleMarginTop: lerp(8, 2, t), + + // Button - always full width, wrapper controls visual width + buttonPaddingY: lerp(celebrationBtnPaddingY, normalBtnPaddingY, t), + buttonPaddingX: 0, // No horizontal padding on button itself + buttonFontSize: lerp(celebrationBtnFontSize, normalBtnFontSize, t), + buttonBackground: gradientToCss(135, lerpGradientStops(celebrationBtn, NORMAL_BTN, t)), + buttonBorderRadius: lerp(12, 0, t), + buttonBoxShadow: boxShadowsToCss(lerpBoxShadows(CELEBRATION_BTN_SHADOW, NORMAL_BTN_SHADOW, t)), + buttonColor: lerpColor(celebrationBtnText, COLORS.normalBtnText, t), + + // Shimmer fades out + shimmerOpacity: 1 - t, + + // Layout - all smoothly interpolated + contentGap: lerp(4, 12, t), // Gap between emoji and text + contentJustify: t, // 0 = centered layout, 1 = left-aligned + textMarginLeft: lerp(0, 0, t), // Could add margin if needed + buttonWrapperPaddingX: lerp(isModal ? 60 : 80, 0, t), // Shrinks button visually in celebration + buttonWrapperPaddingBottom: lerp(celebrationPadding, 0, t), + } +} + +// ============================================================================= +// Component +// ============================================================================= + +export function CelebrationProgressionBanner({ + mode, + onAction, + isLoading, + variant, + isDark, + speedMultiplier = 1, + forceProgress, + disableConfetti = false, +}: CelebrationProgressionBannerProps) { + const confettiFiredRef = useRef(false) + + const { progress, shouldFireConfetti, oscillation, onConfettiFired, isCelebrating } = + useCelebrationWindDown({ + skillId: mode.nextSkill.skillId, + tutorialRequired: mode.tutorialRequired, + enabled: true, + speedMultiplier, + forceProgress, + }) + + // Fire confetti once (unless disabled for Storybook) + useEffect(() => { + if (shouldFireConfetti && !confettiFiredRef.current && !disableConfetti) { + confettiFiredRef.current = true + fireConfettiCelebration() + onConfettiFired() + } + }, [shouldFireConfetti, onConfettiFired, disableConfetti]) + + // If not celebrating at all, render the normal banner + if (!isCelebrating && progress >= 1) { + return ( + + ) + } + + // Calculate all interpolated styles + const styles = calculateStyles(progress, oscillation, isDark, variant) + + return ( +
+ {/* Shimmer overlay - fades out */} +
+ + {/* Content area - always row layout, centering achieved via flexbox */} +
+ {/* Emoji - single emoji with size and wiggle animation */} + + 🌟 + + + {/* Text content */} +
+ {/* Title - same text throughout, only styling changes */} +
+ New Skill Unlocked: {mode.nextSkill.displayName} +
+ + {/* Subtitle - same text throughout */} +
+ Ready to start the tutorial +
+
+
+ + {/* Button wrapper - padding interpolates to control visual button width */} +
+ +
+ + {/* Inject keyframes for shimmer animation */} + +
+ ) +} + +// ============================================================================= +// Normal Progression Banner (fallback when celebration is complete) +// ============================================================================= + +function NormalProgressionBanner({ + mode, + onAction, + isLoading, + variant, + isDark, +}: Omit< + CelebrationProgressionBannerProps, + 'speedMultiplier' | 'forceProgress' | 'disableConfetti' +>) { + return ( +
+ {/* Info section */} +
+ + 🌟 + +
+

+ {mode.tutorialRequired ? 'New Skill Unlocked: ' : 'Ready to practice: '} + {mode.nextSkill.displayName} +

+

+ {mode.tutorialRequired ? 'Ready to start the tutorial' : 'Continue building mastery'} +

+
+
+ + {/* Action button */} + +
+ ) +} diff --git a/apps/web/src/hooks/useCelebrationWindDown.ts b/apps/web/src/hooks/useCelebrationWindDown.ts new file mode 100644 index 00000000..cf1c1482 --- /dev/null +++ b/apps/web/src/hooks/useCelebrationWindDown.ts @@ -0,0 +1,213 @@ +/** + * Hook for managing celebration wind-down state + * + * Tracks when a skill unlock celebration started and provides + * smooth progress updates via requestAnimationFrame. + */ + +import { useCallback, useEffect, useRef, useState } from 'react' +import { windDownProgress } from '@/utils/interpolate' + +// ============================================================================= +// Types & Constants +// ============================================================================= + +interface CelebrationState { + skillId: string + startedAt: number + confettiFired: boolean +} + +const CELEBRATION_STORAGE_KEY = 'skill-celebration-state' + +// ============================================================================= +// localStorage Helpers +// ============================================================================= + +function getCelebrationState(): CelebrationState | null { + if (typeof window === 'undefined') return null + + try { + const stored = localStorage.getItem(CELEBRATION_STORAGE_KEY) + if (!stored) return null + return JSON.parse(stored) as CelebrationState + } catch { + return null + } +} + +function setCelebrationState(state: CelebrationState): void { + if (typeof window === 'undefined') return + + try { + localStorage.setItem(CELEBRATION_STORAGE_KEY, JSON.stringify(state)) + } catch { + // Ignore localStorage errors + } +} + +function markConfettiFired(skillId: string): void { + const state = getCelebrationState() + if (state && state.skillId === skillId) { + setCelebrationState({ ...state, confettiFired: true }) + } +} + +// ============================================================================= +// Hook +// ============================================================================= + +interface UseCelebrationWindDownOptions { + /** The skill ID that was unlocked */ + skillId: string + /** Whether this mode requires a tutorial (celebration only for tutorial-required skills) */ + tutorialRequired: boolean + /** Whether to enable the celebration (pass false to skip) */ + enabled?: boolean + /** Speed multiplier for testing (1 = normal, 10 = 10x faster, 60 = see full transition in 1 second) */ + speedMultiplier?: number + /** Force a specific progress value (for Storybook stories) */ + forceProgress?: number +} + +interface UseCelebrationWindDownResult { + /** Progress from 0 (full celebration) to 1 (fully normal) */ + progress: number + /** Whether confetti should be fired (only true once per skill) */ + shouldFireConfetti: boolean + /** Current oscillation value for wiggle animation (-1 to 1) */ + oscillation: number + /** Mark confetti as fired (call after firing) */ + onConfettiFired: () => void + /** Whether we're in celebration mode at all */ + isCelebrating: boolean +} + +export function useCelebrationWindDown({ + skillId, + tutorialRequired, + enabled = true, + speedMultiplier = 1, + forceProgress, +}: UseCelebrationWindDownOptions): UseCelebrationWindDownResult { + const [progress, setProgress] = useState(1) // Start at 1 (normal) until we check state + const [shouldFireConfetti, setShouldFireConfetti] = useState(false) + const [oscillation, setOscillation] = useState(0) + const [isCelebrating, setIsCelebrating] = useState(false) + + const rafIdRef = useRef(null) + const confettiFiredRef = useRef(false) + const startTimeRef = useRef(null) + + const onConfettiFired = useCallback(() => { + if (skillId) { + markConfettiFired(skillId) + confettiFiredRef.current = true + setShouldFireConfetti(false) + } + }, [skillId]) + + useEffect(() => { + // If forceProgress is set, use it directly (for Storybook) + if (forceProgress !== undefined) { + setProgress(forceProgress) + setIsCelebrating(forceProgress < 1) + // Still calculate oscillation for wiggle + const osc = Math.sin(Date.now() / 250) + setOscillation(osc) + + // Keep updating oscillation + const animate = () => { + const osc = Math.sin(Date.now() / 250) + setOscillation(osc) + rafIdRef.current = requestAnimationFrame(animate) + } + rafIdRef.current = requestAnimationFrame(animate) + + return () => { + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current) + } + } + } + + // Don't celebrate if disabled or no tutorial required + if (!enabled || !tutorialRequired || !skillId) { + setProgress(1) + setIsCelebrating(false) + setShouldFireConfetti(false) + return + } + + // Check existing celebration state + const existingState = getCelebrationState() + + if (!existingState || existingState.skillId !== skillId) { + // New skill unlock! Start fresh celebration + const newState: CelebrationState = { + skillId, + startedAt: Date.now(), + confettiFired: false, + } + setCelebrationState(newState) + startTimeRef.current = Date.now() + setProgress(0) + setIsCelebrating(true) + setShouldFireConfetti(true) + confettiFiredRef.current = false + } else { + // Existing celebration - calculate current progress + startTimeRef.current = existingState.startedAt + const elapsed = (Date.now() - existingState.startedAt) * speedMultiplier + const currentProgress = windDownProgress(elapsed) + setProgress(currentProgress) + setIsCelebrating(currentProgress < 1) + + // Only fire confetti if it hasn't been fired yet + if (!existingState.confettiFired && !confettiFiredRef.current) { + setShouldFireConfetti(true) + } + } + + // Animation loop for smooth updates + const animate = () => { + const state = getCelebrationState() + if (!state || state.skillId !== skillId) { + setProgress(1) + setIsCelebrating(false) + return + } + + // Apply speed multiplier to elapsed time + const elapsed = (Date.now() - state.startedAt) * speedMultiplier + const newProgress = windDownProgress(elapsed) + + setProgress(newProgress) + setIsCelebrating(newProgress < 1) + + // Oscillation for wiggle (period of ~500ms, also sped up) + const osc = Math.sin((Date.now() * speedMultiplier) / 250) + setOscillation(osc) + + if (newProgress < 1) { + rafIdRef.current = requestAnimationFrame(animate) + } + } + + rafIdRef.current = requestAnimationFrame(animate) + + return () => { + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current) + } + } + }, [skillId, tutorialRequired, enabled, speedMultiplier, forceProgress]) + + return { + progress, + shouldFireConfetti, + oscillation, + onConfettiFired, + isCelebrating, + } +} diff --git a/apps/web/src/utils/interpolate.ts b/apps/web/src/utils/interpolate.ts new file mode 100644 index 00000000..fcd47498 --- /dev/null +++ b/apps/web/src/utils/interpolate.ts @@ -0,0 +1,347 @@ +/** + * Interpolation utilities for smooth CSS property transitions + * + * Used by the celebration wind-down system to morph between + * celebration and normal banner states over ~60 seconds. + */ + +// ============================================================================= +// Types +// ============================================================================= + +export interface RGB { + r: number + g: number + b: number +} + +export interface RGBA extends RGB { + a: number +} + +export interface GradientStop { + color: RGBA + position: number // percentage 0-100 +} + +export interface BoxShadow { + x: number + y: number + blur: number + spread: number + color: RGBA + inset?: boolean +} + +// ============================================================================= +// Basic Interpolation +// ============================================================================= + +/** + * Linear interpolation between two numbers + */ +export function lerp(start: number, end: number, t: number): number { + return start + (end - start) * t +} + +/** + * Clamp a value between min and max + */ +export function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max) +} + +// ============================================================================= +// Color Parsing & Interpolation +// ============================================================================= + +/** + * Parse a hex color to RGB + */ +export function hexToRgb(hex: string): RGB { + // Remove # if present + const cleanHex = hex.replace('#', '') + + // Handle 3-char hex + const fullHex = + cleanHex.length === 3 + ? cleanHex + .split('') + .map((c) => c + c) + .join('') + : cleanHex + + const num = parseInt(fullHex, 16) + return { + r: (num >> 16) & 255, + g: (num >> 8) & 255, + b: num & 255, + } +} + +/** + * Convert RGB to hex string + */ +export function rgbToHex(r: number, g: number, b: number): string { + const toHex = (n: number) => + Math.round(clamp(n, 0, 255)) + .toString(16) + .padStart(2, '0') + return `#${toHex(r)}${toHex(g)}${toHex(b)}` +} + +/** + * Parse an rgba() or rgb() string to RGBA + */ +export function parseRgba(str: string): RGBA { + // Handle rgba(r, g, b, a) or rgb(r, g, b) + const match = str.match( + /rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/ + ) + if (match) { + return { + r: parseFloat(match[1]), + g: parseFloat(match[2]), + b: parseFloat(match[3]), + a: match[4] !== undefined ? parseFloat(match[4]) : 1, + } + } + + // Handle hex + if (str.startsWith('#')) { + const rgb = hexToRgb(str) + return { ...rgb, a: 1 } + } + + // Default to black + return { r: 0, g: 0, b: 0, a: 1 } +} + +/** + * Interpolate between two hex colors + */ +export function lerpColor(startHex: string, endHex: string, t: number): string { + const start = hexToRgb(startHex) + const end = hexToRgb(endHex) + return rgbToHex(lerp(start.r, end.r, t), lerp(start.g, end.g, t), lerp(start.b, end.b, t)) +} + +/** + * Interpolate between two RGBA colors + */ +export function lerpRgba(start: RGBA, end: RGBA, t: number): RGBA { + return { + r: lerp(start.r, end.r, t), + g: lerp(start.g, end.g, t), + b: lerp(start.b, end.b, t), + a: lerp(start.a, end.a, t), + } +} + +/** + * Convert RGBA to CSS string + */ +export function rgbaToString(rgba: RGBA): string { + return `rgba(${Math.round(rgba.r)}, ${Math.round(rgba.g)}, ${Math.round(rgba.b)}, ${rgba.a.toFixed(3)})` +} + +/** + * Interpolate between two RGBA colors and return CSS string + */ +export function lerpRgbaString(start: RGBA, end: RGBA, t: number): string { + return rgbaToString(lerpRgba(start, end, t)) +} + +// ============================================================================= +// Gradient Interpolation +// ============================================================================= + +/** + * Create a gradient stop + */ +export function gradientStop( + r: number, + g: number, + b: number, + a: number, + position: number +): GradientStop { + return { color: { r, g, b, a }, position } +} + +/** + * Interpolate between two gradient stop arrays + * Both arrays must have the same number of stops + */ +export function lerpGradientStops( + start: GradientStop[], + end: GradientStop[], + t: number +): GradientStop[] { + if (start.length !== end.length) { + throw new Error('Gradient stop arrays must have the same length') + } + + return start.map((startStop, i) => { + const endStop = end[i] + return { + color: lerpRgba(startStop.color, endStop.color, t), + position: lerp(startStop.position, endStop.position, t), + } + }) +} + +/** + * Convert gradient stops to CSS linear-gradient string + */ +export function gradientToCss(angle: number, stops: GradientStop[]): string { + const stopsStr = stops.map((s) => `${rgbaToString(s.color)} ${s.position}%`).join(', ') + return `linear-gradient(${angle}deg, ${stopsStr})` +} + +/** + * Interpolate between two linear gradients + */ +export function lerpGradient( + startAngle: number, + startStops: GradientStop[], + endAngle: number, + endStops: GradientStop[], + t: number +): string { + const angle = lerp(startAngle, endAngle, t) + const stops = lerpGradientStops(startStops, endStops, t) + return gradientToCss(angle, stops) +} + +// ============================================================================= +// Box Shadow Interpolation +// ============================================================================= + +/** + * Create a box shadow + */ +export function boxShadow( + x: number, + y: number, + blur: number, + spread: number, + r: number, + g: number, + b: number, + a: number, + inset = false +): BoxShadow { + return { x, y, blur, spread, color: { r, g, b, a }, inset } +} + +/** + * Create a transparent (invisible) shadow for padding arrays + */ +export function transparentShadow(): BoxShadow { + return boxShadow(0, 0, 0, 0, 0, 0, 0, 0) +} + +/** + * Pad shadow array to target length with transparent shadows + */ +function padShadows(shadows: BoxShadow[], targetLength: number): BoxShadow[] { + const result = [...shadows] + while (result.length < targetLength) { + result.push(transparentShadow()) + } + return result +} + +/** + * Interpolate between two box shadows + */ +export function lerpBoxShadowSingle(start: BoxShadow, end: BoxShadow, t: number): BoxShadow { + return { + x: lerp(start.x, end.x, t), + y: lerp(start.y, end.y, t), + blur: lerp(start.blur, end.blur, t), + spread: lerp(start.spread, end.spread, t), + color: lerpRgba(start.color, end.color, t), + inset: t < 0.5 ? start.inset : end.inset, + } +} + +/** + * Interpolate between two box shadow arrays + */ +export function lerpBoxShadows(start: BoxShadow[], end: BoxShadow[], t: number): BoxShadow[] { + const maxLen = Math.max(start.length, end.length) + const paddedStart = padShadows(start, maxLen) + const paddedEnd = padShadows(end, maxLen) + + return paddedStart.map((s, i) => lerpBoxShadowSingle(s, paddedEnd[i], t)) +} + +/** + * Convert box shadow to CSS string + */ +export function boxShadowToCss(shadow: BoxShadow): string { + const { x, y, blur, spread, color, inset } = shadow + const insetStr = inset ? 'inset ' : '' + return `${insetStr}${x}px ${y}px ${blur}px ${spread}px ${rgbaToString(color)}` +} + +/** + * Convert box shadow array to CSS string + */ +export function boxShadowsToCss(shadows: BoxShadow[]): string { + // Filter out completely transparent shadows + const visible = shadows.filter((s) => s.color.a > 0.001 || s.blur > 0) + if (visible.length === 0) return 'none' + return visible.map(boxShadowToCss).join(', ') +} + +/** + * Interpolate between two box shadow arrays and return CSS string + */ +export function lerpBoxShadowString(start: BoxShadow[], end: BoxShadow[], t: number): string { + return boxShadowsToCss(lerpBoxShadows(start, end, t)) +} + +// ============================================================================= +// Timing Functions +// ============================================================================= + +/** + * Quintic ease-out: starts extremely slow, accelerates toward end + * Perfect for imperceptible transitions + */ +export function easeOutQuint(t: number): number { + return 1 - (1 - t) ** 5 +} + +/** + * Quartic ease-out: slightly faster than quintic + */ +export function easeOutQuart(t: number): number { + return 1 - (1 - t) ** 4 +} + +/** + * Calculate wind-down progress for celebration banner + * + * @param elapsedMs - milliseconds since celebration started + * @returns progress from 0 (full celebration) to 1 (fully normal) + */ +export function windDownProgress(elapsedMs: number): number { + const BURST_DURATION_MS = 5_000 // 5 seconds of full celebration + const WIND_DOWN_DURATION_MS = 55_000 // 55 seconds to transition + + if (elapsedMs < BURST_DURATION_MS) { + return 0 // Full celebration mode + } + + const windDownElapsed = elapsedMs - BURST_DURATION_MS + if (windDownElapsed >= WIND_DOWN_DURATION_MS) { + return 1 // Fully transitioned to normal + } + + const linearProgress = windDownElapsed / WIND_DOWN_DURATION_MS + return easeOutQuint(linearProgress) +}