feat: Add TimeMachineHistory component with 3D perspective stack
New component that displays problem evolution as an Apple Time Machine-style stack of cards: - Current problem is prominent (full opacity, full scale) at the front - Previous problems are stacked behind with perspective depth - Each layer: translateZ, scale down, fade opacity - Clicking past layers triggers navigation to rewind - Smooth animation when new entries are added Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
97fdcd9484
commit
ce210406b9
|
|
@ -80,6 +80,8 @@ export default defineConfig({
|
|||
bounceIn: { value: 'bounceIn 1s ease-out' },
|
||||
// Glow animation (line 6260)
|
||||
glow: { value: 'glow 1s ease-in-out infinite alternate' },
|
||||
// Time machine enter animation
|
||||
timeMachineEnter: { value: 'timeMachineEnter 0.5s ease-out' },
|
||||
},
|
||||
},
|
||||
// Semantic color tokens for light/dark theme support
|
||||
|
|
@ -303,6 +305,17 @@ export default defineConfig({
|
|||
'60%': { opacity: '1', transform: 'scale(1.1)' },
|
||||
'100%': { opacity: '1', transform: 'scale(1)' },
|
||||
},
|
||||
// Time machine enter - new history entry slides in from the void
|
||||
timeMachineEnter: {
|
||||
'0%': {
|
||||
opacity: '0',
|
||||
transform: 'translateZ(-60px) scale(0.85)',
|
||||
},
|
||||
'100%': {
|
||||
opacity: '1',
|
||||
transform: 'translateZ(0) scale(1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,266 @@
|
|||
'use client'
|
||||
|
||||
import { useRef, useEffect } from 'react'
|
||||
import type { WorkingProblemHistoryEntry } from '@/lib/flowcharts/schema'
|
||||
import { css, cx } from '../../../styled-system/css'
|
||||
import { vstack } from '../../../styled-system/patterns'
|
||||
import { MathDisplay } from './MathDisplay'
|
||||
|
||||
interface TimeMachineHistoryProps {
|
||||
/** History of working problem transformations */
|
||||
history: WorkingProblemHistoryEntry[]
|
||||
/** Callback when user clicks a history entry to rewind */
|
||||
onNavigate: (index: number) => void
|
||||
/** Font size for the current (hero) problem */
|
||||
fontSize?: 'lg' | 'xl'
|
||||
}
|
||||
|
||||
/**
|
||||
* TimeMachineHistory - Apple Time Machine-style 3D perspective stack
|
||||
*
|
||||
* Displays the problem evolution as a stack of cards with the current
|
||||
* problem in front (hero) and past states fading into the background.
|
||||
*
|
||||
* - Current problem is prominent (full opacity, full scale)
|
||||
* - Previous problems are stacked behind with perspective depth
|
||||
* - Each layer: translateZ, scale down, fade opacity
|
||||
* - Clicking past layers triggers onNavigate to rewind
|
||||
*/
|
||||
export function TimeMachineHistory({
|
||||
history,
|
||||
onNavigate,
|
||||
fontSize = 'xl',
|
||||
}: TimeMachineHistoryProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const latestEntryRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Scroll to show latest entry when history changes
|
||||
useEffect(() => {
|
||||
if (latestEntryRef.current) {
|
||||
latestEntryRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
})
|
||||
}
|
||||
}, [history.length])
|
||||
|
||||
if (history.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// How many history items to show (limit to prevent overwhelming)
|
||||
const maxVisible = 5
|
||||
const visibleHistory = history.slice(-maxVisible)
|
||||
const hiddenCount = history.length - visibleHistory.length
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-testid="time-machine-history"
|
||||
data-entry-count={history.length}
|
||||
data-visible-count={visibleHistory.length}
|
||||
className={css({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
perspective: '800px',
|
||||
perspectiveOrigin: 'center bottom',
|
||||
padding: '4',
|
||||
})}
|
||||
>
|
||||
{/* Stack container */}
|
||||
<div
|
||||
data-element="stack-container"
|
||||
className={vstack({
|
||||
gap: '3',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
})}
|
||||
>
|
||||
{/* Hidden count indicator */}
|
||||
{hiddenCount > 0 && (
|
||||
<div
|
||||
data-element="hidden-indicator"
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
color: { base: 'gray.400', _dark: 'gray.500' },
|
||||
textAlign: 'center',
|
||||
padding: '1 2',
|
||||
})}
|
||||
>
|
||||
{hiddenCount} earlier step{hiddenCount > 1 ? 's' : ''} hidden
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History entries */}
|
||||
{visibleHistory.map((entry, visibleIdx) => {
|
||||
// Calculate actual index in full history
|
||||
const actualIdx = history.length - visibleHistory.length + visibleIdx
|
||||
const isLatest = actualIdx === history.length - 1
|
||||
// Depth from current: 0 for latest, 1 for previous, etc.
|
||||
const depth = visibleHistory.length - 1 - visibleIdx
|
||||
|
||||
// 3D transform values based on depth
|
||||
const translateZ = -depth * 20 // Push back
|
||||
const scale = 1 - depth * 0.05 // Shrink slightly
|
||||
const opacity = 1 - depth * 0.15 // Fade
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${actualIdx}-${entry.nodeId}`}
|
||||
ref={isLatest ? latestEntryRef : undefined}
|
||||
data-testid={`time-machine-entry-${actualIdx}`}
|
||||
data-entry-index={actualIdx}
|
||||
data-is-latest={isLatest}
|
||||
data-is-clickable={!isLatest}
|
||||
data-depth={depth}
|
||||
onClick={!isLatest ? () => onNavigate(actualIdx) : undefined}
|
||||
role={!isLatest ? 'button' : undefined}
|
||||
tabIndex={!isLatest ? 0 : undefined}
|
||||
onKeyDown={
|
||||
!isLatest
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onNavigate(actualIdx)
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={cx(
|
||||
css({
|
||||
width: '100%',
|
||||
padding: isLatest ? '4 5' : '3 4',
|
||||
borderRadius: 'xl',
|
||||
textAlign: 'center',
|
||||
transformStyle: 'preserve-3d',
|
||||
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
willChange: 'transform, opacity',
|
||||
|
||||
// Base styling
|
||||
backgroundColor: isLatest
|
||||
? { base: 'blue.50', _dark: 'blue.900' }
|
||||
: { base: 'gray.50', _dark: 'gray.800' },
|
||||
border: isLatest ? '3px solid' : '2px solid',
|
||||
borderColor: isLatest
|
||||
? { base: 'blue.400', _dark: 'blue.500' }
|
||||
: { base: 'gray.200', _dark: 'gray.700' },
|
||||
boxShadow: isLatest ? 'lg' : 'sm',
|
||||
|
||||
// Interactive states for non-latest entries
|
||||
cursor: isLatest ? 'default' : 'pointer',
|
||||
_hover: isLatest
|
||||
? {}
|
||||
: {
|
||||
opacity: '1 !important',
|
||||
backgroundColor: { base: 'blue.50', _dark: 'blue.900/50' },
|
||||
borderColor: { base: 'blue.300', _dark: 'blue.600' },
|
||||
transform: 'translateZ(0) scale(1) !important',
|
||||
},
|
||||
_focusVisible: isLatest
|
||||
? {}
|
||||
: {
|
||||
outline: '2px solid',
|
||||
outlineColor: { base: 'blue.500', _dark: 'blue.400' },
|
||||
outlineOffset: '2px',
|
||||
},
|
||||
}),
|
||||
// Animation class for new entries
|
||||
isLatest &&
|
||||
css({
|
||||
animation: 'timeMachineEnter 0.5s ease-out',
|
||||
})
|
||||
)}
|
||||
style={{
|
||||
transform: `translateZ(${translateZ}px) scale(${scale})`,
|
||||
opacity,
|
||||
zIndex: visibleHistory.length - depth,
|
||||
}}
|
||||
>
|
||||
{/* Math expression */}
|
||||
<div
|
||||
data-element="problem-expression"
|
||||
className={css({
|
||||
color: isLatest
|
||||
? { base: 'blue.900', _dark: 'blue.100' }
|
||||
: { base: 'gray.700', _dark: 'gray.300' },
|
||||
})}
|
||||
>
|
||||
<MathDisplay
|
||||
expression={entry.value}
|
||||
size={isLatest ? fontSize : depth === 1 ? 'lg' : 'md'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step label - shown for all entries */}
|
||||
<div
|
||||
data-element="step-label"
|
||||
className={css({
|
||||
fontSize: isLatest ? 'sm' : 'xs',
|
||||
fontWeight: 'medium',
|
||||
marginTop: '2',
|
||||
color: isLatest
|
||||
? { base: 'blue.600', _dark: 'blue.400' }
|
||||
: { base: 'gray.500', _dark: 'gray.500' },
|
||||
})}
|
||||
>
|
||||
{entry.label}
|
||||
</div>
|
||||
|
||||
{/* Step number badge */}
|
||||
<div
|
||||
data-element="step-number"
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '-2',
|
||||
left: '-2',
|
||||
width: '6',
|
||||
height: '6',
|
||||
borderRadius: 'full',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 'xs',
|
||||
fontWeight: 'bold',
|
||||
backgroundColor: isLatest
|
||||
? { base: 'blue.500', _dark: 'blue.500' }
|
||||
: { base: 'gray.400', _dark: 'gray.600' },
|
||||
color: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: { base: 'white', _dark: 'gray.900' },
|
||||
})}
|
||||
>
|
||||
{actualIdx + 1}
|
||||
</div>
|
||||
|
||||
{/* "Current" indicator for latest */}
|
||||
{isLatest && (
|
||||
<div
|
||||
data-element="current-indicator"
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '-2',
|
||||
right: '-2',
|
||||
padding: '0.5 2',
|
||||
borderRadius: 'full',
|
||||
fontSize: '2xs',
|
||||
fontWeight: 'bold',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 'wide',
|
||||
backgroundColor: { base: 'blue.500', _dark: 'blue.500' },
|
||||
color: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: { base: 'white', _dark: 'gray.900' },
|
||||
})}
|
||||
>
|
||||
Now
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue