feat(practice): add celebration progression banner with smooth transitions

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 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-18 13:42:29 -06:00
parent 56742c511d
commit bb9506b93e
4 changed files with 1938 additions and 0 deletions

View File

@ -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 (
<div
className={css({
padding: '2rem',
minHeight: '300px',
maxWidth: '500px',
margin: '0 auto',
})}
style={{
backgroundColor: isDark ? '#1a1a2e' : '#f5f5f5',
borderRadius: '12px',
}}
data-theme={isDark ? 'dark' : 'light'}
>
{children}
</div>
)
}
// 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 (
<StoryWrapper isDark={isDark}>
<div className={css({ marginBottom: '1rem' })}>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '1rem',
marginBottom: '0.75rem',
flexWrap: 'wrap',
})}
>
<label
className={css({ fontSize: '0.875rem', fontWeight: '600' })}
style={{ color: isDark ? '#e5e7eb' : '#374151' }}
>
Speed: {speed}x
</label>
<input
type="range"
min="1"
max="120"
value={speed}
onChange={(e) => setSpeed(Number(e.target.value))}
className={css({ flex: 1, minWidth: '100px' })}
/>
<button
type="button"
onClick={handleReset}
className={css({
padding: '0.5rem 1rem',
fontSize: '0.75rem',
fontWeight: 'bold',
borderRadius: '6px',
border: 'none',
cursor: 'pointer',
})}
style={{
backgroundColor: isDark ? '#374151' : '#e5e7eb',
color: isDark ? '#e5e7eb' : '#374151',
}}
>
🔄 Reset Celebration
</button>
</div>
<p
className={css({ fontSize: '0.75rem', fontStyle: 'italic' })}
style={{ color: isDark ? '#9ca3af' : '#6b7280' }}
>
At {speed}x, the full 60-second transition takes {Math.round(60 / speed)} seconds
</p>
</div>
<CelebrationProgressionBanner
key={key}
mode={mockProgressionMode}
onAction={() => console.log('Action clicked')}
isLoading={false}
variant="dashboard"
isDark={isDark}
speedMultiplier={speed}
disableConfetti={speed > 10} // Disable confetti at high speeds
/>
</StoryWrapper>
)
}
// Static progress snapshot component
function ProgressSnapshot({
progress,
isDark = false,
variant = 'dashboard',
}: {
progress: number
isDark?: boolean
variant?: 'dashboard' | 'modal'
}) {
return (
<StoryWrapper isDark={isDark}>
<div
className={css({ marginBottom: '0.75rem', textAlign: 'center' })}
style={{ color: isDark ? '#e5e7eb' : '#374151' }}
>
<span className={css({ fontSize: '0.875rem', fontWeight: '600' })}>
Progress: {Math.round(progress * 100)}%
</span>
<span className={css({ fontSize: '0.75rem', marginLeft: '0.5rem', opacity: 0.7 })}>
(
{progress === 0 ? 'Full celebration' : progress === 1 ? 'Normal banner' : 'Transitioning'}
)
</span>
</div>
<CelebrationProgressionBanner
mode={mockProgressionMode}
onAction={() => console.log('Action clicked')}
isLoading={false}
variant={variant}
isDark={isDark}
forceProgress={progress}
disableConfetti
/>
</StoryWrapper>
)
}
// 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 (
<div
className={css({
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(350px, 1fr))',
gap: '1.5rem',
padding: '1rem',
})}
style={{ backgroundColor: isDark ? '#111827' : '#e5e7eb' }}
>
{progressSteps.map((progress) => (
<ProgressSnapshot key={progress} progress={progress} isDark={isDark} />
))}
</div>
)
}
// =============================================================================
// Meta
// =============================================================================
const meta: Meta<typeof CelebrationProgressionBanner> = {
title: 'Practice/CelebrationProgressionBanner',
component: CelebrationProgressionBanner,
parameters: {
layout: 'fullscreen',
},
tags: ['autodocs'],
}
export default meta
type Story = StoryObj<typeof CelebrationProgressionBanner>
// =============================================================================
// 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: () => <InteractiveCelebration initialSpeed={10} />,
}
/**
* Same as Interactive but in dark mode
*/
export const InteractiveDark: Story = {
render: () => <InteractiveCelebration isDark initialSpeed={10} />,
}
/**
* Full celebration state (progress = 0%)
* This is what shows immediately after unlocking a skill.
*/
export const FullCelebration: Story = {
render: () => <ProgressSnapshot progress={0} />,
}
/**
* 25% through the transition
*/
export const Progress25: Story = {
render: () => <ProgressSnapshot progress={0.25} />,
}
/**
* Halfway through the transition (50%)
*/
export const Progress50: Story = {
render: () => <ProgressSnapshot progress={0.5} />,
}
/**
* 75% through the transition
*/
export const Progress75: Story = {
render: () => <ProgressSnapshot progress={0.75} />,
}
/**
* Fully transitioned to normal banner (progress = 100%)
*/
export const FullyNormal: Story = {
render: () => <ProgressSnapshot progress={1} />,
}
/**
* All progress states side by side for comparison
*/
export const ProgressComparison: Story = {
render: () => <ProgressGrid />,
}
/**
* All progress states in dark mode
*/
export const ProgressComparisonDark: Story = {
render: () => <ProgressGrid isDark />,
}
/**
* Modal variant at full celebration
*/
export const ModalVariantCelebration: Story = {
render: () => <ProgressSnapshot progress={0} variant="modal" />,
}
/**
* Modal variant at 50% progress
*/
export const ModalVariantMidway: Story = {
render: () => <ProgressSnapshot progress={0.5} variant="modal" />,
}
/**
* Modal variant fully transitioned
*/
export const ModalVariantNormal: Story = {
render: () => <ProgressSnapshot progress={1} variant="modal" />,
}
/**
* Dark mode full celebration
*/
export const DarkModeCelebration: Story = {
render: () => <ProgressSnapshot progress={0} isDark />,
}
/**
* Dark mode 50% progress
*/
export const DarkModeMidway: Story = {
render: () => <ProgressSnapshot progress={0.5} isDark />,
}
/**
* Dark mode fully normal
*/
export const DarkModeNormal: Story = {
render: () => <ProgressSnapshot progress={1} isDark />,
}
/**
* 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 (
<div className={css({ padding: '1rem' })}>
<h3
className={css({
fontSize: '1rem',
fontWeight: 'bold',
marginBottom: '1rem',
textAlign: 'center',
})}
>
Early transition (0-15%) - Should be nearly imperceptible
</h3>
<div
className={css({
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '1rem',
})}
>
{earlySteps.map((progress) => (
<ProgressSnapshot key={progress} progress={progress} />
))}
</div>
</div>
)
},
}
/**
* 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 (
<div className={css({ padding: '1rem' })}>
<h3
className={css({
fontSize: '1rem',
fontWeight: 'bold',
marginBottom: '1rem',
textAlign: 'center',
})}
>
Late transition (70-100%) - More noticeable changes
</h3>
<div
className={css({
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '1rem',
})}
>
{lateSteps.map((progress) => (
<ProgressSnapshot key={progress} progress={progress} />
))}
</div>
</div>
)
},
}
/**
* 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 (
<StoryWrapper>
<p
className={css({
fontSize: '0.75rem',
marginBottom: '1rem',
textAlign: 'center',
color: 'gray.600',
})}
>
Confetti should fire once when this story loads
</p>
<CelebrationProgressionBanner
mode={mockProgressionMode}
onAction={() => console.log('Action clicked')}
isLoading={false}
variant="dashboard"
isDark={false}
speedMultiplier={30} // Fast transition to see the full effect quickly
/>
</StoryWrapper>
)
},
}
// =============================================================================
// 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 (
<div
className={css({ padding: '1.5rem', minHeight: '500px' })}
style={{ backgroundColor: isDark ? '#111827' : '#f3f4f6' }}
data-theme={isDark ? 'dark' : 'light'}
>
{/* Header */}
<div
className={css({
maxWidth: '600px',
margin: '0 auto 1.5rem',
textAlign: 'center',
})}
>
<h2
className={css({ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '0.5rem' })}
style={{ color: isDark ? '#f3f4f6' : '#111827' }}
>
Progress Scrubber
</h2>
<p
className={css({ fontSize: '0.875rem' })}
style={{ color: isDark ? '#9ca3af' : '#6b7280' }}
>
Drag the slider to see the transition at any point
</p>
</div>
{/* Current progress display */}
<div
className={css({
maxWidth: '600px',
margin: '0 auto 1rem',
padding: '1rem',
borderRadius: '8px',
textAlign: 'center',
})}
style={{
backgroundColor: isDark ? '#1f2937' : '#ffffff',
border: `1px solid ${isDark ? '#374151' : '#e5e7eb'}`,
}}
>
<div
className={css({
fontSize: '2rem',
fontWeight: 'bold',
fontFamily: 'monospace',
marginBottom: '0.25rem',
})}
style={{ color: isDark ? '#60a5fa' : '#2563eb' }}
>
{(progress * 100).toFixed(1)}%
</div>
<div
className={css({ fontSize: '0.875rem', fontWeight: '500' })}
style={{ color: isDark ? '#d1d5db' : '#374151' }}
>
{exactLabel?.description || closestLabel.description}
</div>
</div>
{/* Slider */}
<div
className={css({
maxWidth: '600px',
margin: '0 auto 1rem',
})}
>
<input
type="range"
min="0"
max="100"
step="0.5"
value={progress * 100}
onChange={(e) => setProgress(Number(e.target.value) / 100)}
className={css({
width: '100%',
height: '8px',
borderRadius: '4px',
cursor: 'pointer',
})}
/>
{/* Tick marks with labels */}
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
marginTop: '0.5rem',
position: 'relative',
height: '20px',
})}
>
{PROGRESS_LABELS.filter((l) => [0, 0.25, 0.5, 0.7, 1].includes(l.value)).map((label) => (
<button
key={label.value}
type="button"
onClick={() => setProgress(label.value)}
className={css({
fontSize: '0.6875rem',
fontWeight: '600',
cursor: 'pointer',
padding: '2px 6px',
borderRadius: '4px',
border: 'none',
transition: 'all 0.15s ease',
})}
style={{
position: 'absolute',
left: `${label.value * 100}%`,
transform: 'translateX(-50%)',
backgroundColor:
Math.abs(progress - label.value) < 0.02
? isDark
? '#3b82f6'
: '#2563eb'
: isDark
? '#374151'
: '#e5e7eb',
color:
Math.abs(progress - label.value) < 0.02
? '#ffffff'
: isDark
? '#9ca3af'
: '#6b7280',
}}
>
{label.label}
</button>
))}
</div>
</div>
{/* Preset buttons */}
<div
className={css({
maxWidth: '600px',
margin: '0 auto 1.5rem',
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
justifyContent: 'center',
})}
>
{PROGRESS_LABELS.map((label) => (
<button
key={label.value}
type="button"
onClick={() => setProgress(label.value)}
className={css({
fontSize: '0.75rem',
padding: '0.375rem 0.75rem',
borderRadius: '6px',
border: 'none',
cursor: 'pointer',
fontWeight: '500',
transition: 'all 0.15s ease',
})}
style={{
backgroundColor:
Math.abs(progress - label.value) < 0.001
? isDark
? '#3b82f6'
: '#2563eb'
: isDark
? '#374151'
: '#e5e7eb',
color:
Math.abs(progress - label.value) < 0.001
? '#ffffff'
: isDark
? '#d1d5db'
: '#374151',
}}
>
{label.label}
</button>
))}
</div>
{/* Banner preview */}
<div className={css({ maxWidth: '500px', margin: '0 auto' })}>
<CelebrationProgressionBanner
mode={mockProgressionMode}
onAction={() => console.log('Action clicked')}
isLoading={false}
variant="dashboard"
isDark={isDark}
forceProgress={progress}
disableConfetti
/>
</div>
{/* Properties being interpolated at this progress */}
<div
className={css({
maxWidth: '600px',
margin: '1.5rem auto 0',
padding: '1rem',
borderRadius: '8px',
fontSize: '0.75rem',
fontFamily: 'monospace',
})}
style={{
backgroundColor: isDark ? '#1f2937' : '#ffffff',
border: `1px solid ${isDark ? '#374151' : '#e5e7eb'}`,
color: isDark ? '#9ca3af' : '#6b7280',
}}
>
<div className={css({ fontWeight: 'bold', marginBottom: '0.5rem' })}>
Key values at {(progress * 100).toFixed(0)}%:
</div>
<div className={css({ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.25rem' })}>
<span>Emoji size:</span>
<span style={{ color: isDark ? '#fbbf24' : '#d97706' }}>
{Math.round(64 - 32 * progress)}px
</span>
<span>Emoji wiggle:</span>
<span style={{ color: isDark ? '#34d399' : '#059669' }}>
±{(3 * (1 - progress)).toFixed(1)}°
</span>
<span>Title size:</span>
<span>{Math.round(28 - 12 * progress)}px</span>
<span>Border width:</span>
<span>{(3 - progress).toFixed(1)}px</span>
<span>Button margin X:</span>
<span>{Math.round(80 - 80 * progress)}px</span>
<span>Button radius:</span>
<span>{Math.round(12 - 12 * progress)}px</span>
<span>Shimmer:</span>
<span>{progress < 0.5 ? 'visible' : progress < 1 ? 'fading' : 'hidden'}</span>
</div>
</div>
</div>
)
}
/**
* 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: () => <ProgressScrubber />,
}
/**
* Same scrubber in dark mode
*/
export const ScrubberDark: Story = {
render: () => <ProgressScrubber isDark />,
}

View File

@ -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 (
<NormalProgressionBanner
mode={mode}
onAction={onAction}
isLoading={isLoading}
variant={variant}
isDark={isDark}
/>
)
}
// Calculate all interpolated styles
const styles = calculateStyles(progress, oscillation, isDark, variant)
return (
<div
data-element="session-mode-banner"
data-mode="progression"
data-variant={variant}
data-celebrating={isCelebrating}
data-progress={progress.toFixed(3)}
style={{
position: 'relative',
background: styles.containerBackground,
borderWidth: `${styles.containerBorderWidth}px`,
borderStyle: 'solid',
borderColor: styles.containerBorderColor,
borderRadius: `${styles.containerBorderRadius}px`,
boxShadow: styles.containerBoxShadow,
overflow: 'hidden',
}}
>
{/* Shimmer overlay - fades out */}
<div
style={{
position: 'absolute',
inset: 0,
background:
'linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.3) 50%, transparent 100%)',
backgroundSize: '200% 100%',
animation: 'celebrationShimmer 2s linear infinite',
opacity: styles.shimmerOpacity,
pointerEvents: 'none',
}}
/>
{/* Content area - always row layout, centering achieved via flexbox */}
<div
style={{
padding: `${styles.containerPadding}px`,
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
gap: `${styles.contentGap}px`,
}}
>
{/* Emoji - single emoji with size and wiggle animation */}
<span
style={{
fontSize: `${styles.emojiSize}px`,
lineHeight: 1,
flexShrink: 0,
display: 'inline-block',
transform: `rotate(${styles.emojiRotation}deg)`,
}}
>
🌟
</span>
{/* Text content */}
<div style={{ flex: 1, minWidth: 0 }}>
{/* Title - same text throughout, only styling changes */}
<div
style={{
fontSize: `${styles.titleFontSize}px`,
fontWeight: 'bold',
color: styles.titleColor,
textShadow: styles.titleTextShadow,
}}
>
New Skill Unlocked: <strong>{mode.nextSkill.displayName}</strong>
</div>
{/* Subtitle - same text throughout */}
<div
style={{
fontSize: `${styles.subtitleFontSize}px`,
color: styles.subtitleColor,
marginTop: `${styles.subtitleMarginTop}px`,
}}
>
Ready to start the tutorial
</div>
</div>
</div>
{/* Button wrapper - padding interpolates to control visual button width */}
<div
style={{
padding: `0 ${styles.buttonWrapperPaddingX}px ${styles.buttonWrapperPaddingBottom}px`,
}}
>
<button
type="button"
data-action="start-progression"
onClick={onAction}
disabled={isLoading}
style={{
width: '100%',
padding: `${styles.buttonPaddingY}px 16px`,
fontSize: `${styles.buttonFontSize}px`,
fontWeight: 'bold',
background: styles.buttonBackground,
color: styles.buttonColor,
borderRadius: `${styles.buttonBorderRadius}px`,
border: 'none',
boxShadow: styles.buttonBoxShadow,
cursor: isLoading ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
}}
>
{isLoading ? 'Starting...' : 'Begin Tutorial →'}
</button>
</div>
{/* Inject keyframes for shimmer animation */}
<style>
{`
@keyframes celebrationShimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
`}
</style>
</div>
)
}
// =============================================================================
// Normal Progression Banner (fallback when celebration is complete)
// =============================================================================
function NormalProgressionBanner({
mode,
onAction,
isLoading,
variant,
isDark,
}: Omit<
CelebrationProgressionBannerProps,
'speedMultiplier' | 'forceProgress' | 'disableConfetti'
>) {
return (
<div
data-element="session-mode-banner"
data-mode="progression"
data-variant={variant}
style={{
borderRadius: variant === 'modal' ? '12px' : '16px',
overflow: 'hidden',
border: '2px solid',
borderColor: isDark ? '#22c55e' : '#4ade80',
background: isDark
? 'linear-gradient(135deg, rgba(34, 197, 94, 0.12) 0%, rgba(59, 130, 246, 0.08) 100%)'
: 'linear-gradient(135deg, rgba(34, 197, 94, 0.06) 0%, rgba(59, 130, 246, 0.04) 100%)',
}}
>
{/* Info section */}
<div
style={{
padding: variant === 'modal' ? '14px 16px' : '16px 20px',
display: 'flex',
gap: '12px',
alignItems: 'center',
}}
>
<span
style={{
fontSize: variant === 'modal' ? '24px' : '32px',
lineHeight: 1,
}}
>
🌟
</span>
<div style={{ flex: 1 }}>
<p
style={{
fontSize: variant === 'modal' ? '15px' : '16px',
fontWeight: 600,
color: isDark ? '#86efac' : '#166534',
margin: 0,
}}
>
{mode.tutorialRequired ? 'New Skill Unlocked: ' : 'Ready to practice: '}
<strong>{mode.nextSkill.displayName}</strong>
</p>
<p
style={{
fontSize: variant === 'modal' ? '12px' : '13px',
marginTop: '2px',
color: isDark ? '#a1a1aa' : '#6b7280',
margin: 0,
}}
>
{mode.tutorialRequired ? 'Ready to start the tutorial' : 'Continue building mastery'}
</p>
</div>
</div>
{/* Action button */}
<button
type="button"
data-action="start-progression"
onClick={onAction}
disabled={isLoading}
style={{
width: '100%',
padding: variant === 'modal' ? '14px' : '16px',
fontSize: variant === 'modal' ? '16px' : '17px',
fontWeight: 'bold',
color: 'white',
border: 'none',
borderRadius: 0,
cursor: isLoading ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
background: isLoading ? '#9ca3af' : 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
boxShadow: isLoading ? 'none' : 'inset 0 1px 0 rgba(255,255,255,0.15)',
}}
>
{isLoading ? 'Starting...' : mode.tutorialRequired ? 'Begin Tutorial →' : "Let's Go! →"}
</button>
</div>
)
}

View File

@ -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<number | null>(null)
const confettiFiredRef = useRef(false)
const startTimeRef = useRef<number | null>(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,
}
}

View File

@ -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)
}