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:
parent
56742c511d
commit
bb9506b93e
|
|
@ -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 />,
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Reference in New Issue