feat(homepage): add full-page hero abacus with scroll-based nav transition
Implement an immersive homepage experience with a large, interactive 4-column abacus that dominates the initial viewport, creating a "play with this" first impression. The hero smoothly transitions to reveal the Abaci One branding in the navigation when scrolled. **New Components:** - `HeroAbacus`: Full-viewport interactive abacus with title/subtitle - Auto-cycles through random 4-digit numbers - Responsive scaling (1.5x mobile, 2x tablet, 2.5x desktop) - Intersection Observer to track visibility - `HomeHeroContext`: Shared state for subtitle and scroll visibility - SSR-safe random subtitle selection (client-side only) - Prevents hydration mismatch warnings - Shares abacus value across hero/nav **Navigation Updates:** - AppNavBar conditionally shows/hides branding based on hero visibility - Smooth fadeIn animation when branding appears after scroll - Uses same random subtitle from context (consistent across page) - Optional context access without breaking other pages **Mobile Support:** - Responsive abacus scaling for all screen sizes - Touch-friendly interactive abacus - Smooth animations work on all devices 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
1d4419364a
commit
d8ec64280e
File diff suppressed because it is too large
Load Diff
|
|
@ -4,7 +4,7 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
|||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import React, { useContext, useMemo, useState } from 'react'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { container, hstack } from '../../styled-system/patterns'
|
||||
import { Z_INDEX } from '../constants/zIndex'
|
||||
|
|
@ -12,6 +12,24 @@ import { useFullscreen } from '../contexts/FullscreenContext'
|
|||
import { getRandomSubtitle } from '../data/abaciOneSubtitles'
|
||||
import { AbacusDisplayDropdown } from './AbacusDisplayDropdown'
|
||||
|
||||
// Import HomeHeroContext for optional usage
|
||||
import type { Subtitle } from '../data/abaciOneSubtitles'
|
||||
|
||||
// HomeHeroContext - imported dynamically to avoid circular deps
|
||||
let HomeHeroContextModule: any = null
|
||||
try {
|
||||
HomeHeroContextModule = require('../contexts/HomeHeroContext')
|
||||
} catch {
|
||||
// Context not available
|
||||
}
|
||||
|
||||
const HomeHeroContext = HomeHeroContextModule?.HomeHeroContext || React.createContext(null)
|
||||
|
||||
// Use HomeHeroContext without requiring it
|
||||
function useOptionalHomeHero(): { subtitle: Subtitle; isHeroVisible: boolean } | null {
|
||||
return useContext(HomeHeroContext)
|
||||
}
|
||||
|
||||
interface AppNavBarProps {
|
||||
variant?: 'full' | 'minimal'
|
||||
navSlot?: React.ReactNode
|
||||
|
|
@ -516,8 +534,16 @@ export function AppNavBar({ variant = 'full', navSlot }: AppNavBarProps) {
|
|||
const isArcadePage = pathname?.startsWith('/arcade')
|
||||
const { isFullscreen, toggleFullscreen, exitFullscreen } = useFullscreen()
|
||||
|
||||
// Try to get home hero context (if on homepage)
|
||||
const homeHero = useOptionalHomeHero()
|
||||
|
||||
// Select a random subtitle once on mount (performance: won't change on re-renders)
|
||||
const subtitle = useMemo(() => getRandomSubtitle(), [])
|
||||
// Use homeHero subtitle if available, otherwise generate one
|
||||
const fallbackSubtitle = useMemo(() => getRandomSubtitle(), [])
|
||||
const subtitle = homeHero?.subtitle || fallbackSubtitle
|
||||
|
||||
// Show branding unless we're on homepage with visible hero
|
||||
const showBranding = !homeHero || !homeHero.isHeroVisible
|
||||
|
||||
// Auto-detect variant based on context
|
||||
const actualVariant = variant === 'full' && (isGamePage || isArcadePage) ? 'minimal' : variant
|
||||
|
|
@ -552,68 +578,72 @@ export function AppNavBar({ variant = 'full', navSlot }: AppNavBarProps) {
|
|||
>
|
||||
<div className={container({ maxW: '7xl', px: '4', py: '3' })}>
|
||||
<div className={hstack({ justify: 'space-between', alignItems: 'center' })}>
|
||||
{/* Logo */}
|
||||
<Link
|
||||
href="/"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0',
|
||||
textDecoration: 'none',
|
||||
_hover: { '& > .brand-name': { color: 'brand.900' } },
|
||||
})}
|
||||
>
|
||||
<span
|
||||
{/* Logo - conditionally shown based on hero visibility */}
|
||||
{showBranding && (
|
||||
<Link
|
||||
href="/"
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'brand.800',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0',
|
||||
textDecoration: 'none',
|
||||
_hover: { '& > .brand-name': { color: 'brand.900' } },
|
||||
opacity: 0,
|
||||
animation: 'fadeIn 0.3s ease-out forwards',
|
||||
})}
|
||||
>
|
||||
🧮 Abaci One
|
||||
</span>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
fontWeight: 'medium',
|
||||
color: 'brand.600',
|
||||
fontStyle: 'italic',
|
||||
cursor: 'help',
|
||||
_hover: { color: 'brand.700' },
|
||||
})}
|
||||
>
|
||||
{subtitle.text}
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
className={css({
|
||||
bg: 'gray.900',
|
||||
color: 'white',
|
||||
px: '3',
|
||||
py: '2',
|
||||
rounded: 'md',
|
||||
fontSize: 'sm',
|
||||
maxW: '250px',
|
||||
shadow: 'lg',
|
||||
zIndex: 50,
|
||||
})}
|
||||
>
|
||||
{subtitle.description}
|
||||
<Tooltip.Arrow
|
||||
<span
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'brand.800',
|
||||
})}
|
||||
>
|
||||
🧮 Abaci One
|
||||
</span>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span
|
||||
className={css({
|
||||
fill: 'gray.900',
|
||||
fontSize: 'xs',
|
||||
fontWeight: 'medium',
|
||||
color: 'brand.600',
|
||||
fontStyle: 'italic',
|
||||
cursor: 'help',
|
||||
_hover: { color: 'brand.700' },
|
||||
})}
|
||||
/>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Link>
|
||||
>
|
||||
{subtitle.text}
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
className={css({
|
||||
bg: 'gray.900',
|
||||
color: 'white',
|
||||
px: '3',
|
||||
py: '2',
|
||||
rounded: 'md',
|
||||
fontSize: 'sm',
|
||||
maxW: '250px',
|
||||
shadow: 'lg',
|
||||
zIndex: 50,
|
||||
})}
|
||||
>
|
||||
{subtitle.description}
|
||||
<Tooltip.Arrow
|
||||
className={css({
|
||||
fill: 'gray.900',
|
||||
})}
|
||||
/>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className={hstack({ gap: '6', alignItems: 'center' })}>
|
||||
{/* Navigation Links */}
|
||||
|
|
@ -635,6 +665,24 @@ export function AppNavBar({ variant = 'full', navSlot }: AppNavBarProps) {
|
|||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Keyframes for fade-in animation */}
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { useHomeHero } from '../contexts/HomeHeroContext'
|
||||
|
||||
export function HeroAbacus() {
|
||||
const { subtitle, abacusValue, setAbacusValue, setIsHeroVisible } = useHomeHero()
|
||||
const appConfig = useAbacusConfig()
|
||||
const heroRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Detect when hero scrolls out of view
|
||||
useEffect(() => {
|
||||
if (!heroRef.current) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
// Hero is visible if more than 20% is in viewport
|
||||
setIsHeroVisible(entry.intersectionRatio > 0.2)
|
||||
},
|
||||
{
|
||||
threshold: [0, 0.2, 0.5, 1],
|
||||
}
|
||||
)
|
||||
|
||||
observer.observe(heroRef.current)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [setIsHeroVisible])
|
||||
|
||||
// Auto-cycle through random numbers (optional - user can also interact)
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const randomNum = Math.floor(Math.random() * 10000) // 0-9999
|
||||
setAbacusValue(randomNum)
|
||||
}, 4000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [setAbacusValue])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={heroRef}
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(17, 24, 39, 1) 0%, rgba(88, 28, 135, 0.3) 50%, rgba(17, 24, 39, 1) 100%)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
px: '4',
|
||||
})}
|
||||
>
|
||||
{/* Background pattern */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
opacity: 0.1,
|
||||
backgroundImage:
|
||||
'radial-gradient(circle at 2px 2px, rgba(255, 255, 255, 0.15) 1px, transparent 0)',
|
||||
backgroundSize: '40px 40px',
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'relative',
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '8',
|
||||
})}
|
||||
>
|
||||
{/* Title */}
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: { base: '4xl', md: '6xl', lg: '7xl' },
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #fbbf24 0%, #f59e0b 50%, #fbbf24 100%)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
🧮 Abaci One
|
||||
</h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<p
|
||||
className={css({
|
||||
fontSize: { base: 'xl', md: '2xl' },
|
||||
fontWeight: 'medium',
|
||||
color: 'purple.300',
|
||||
fontStyle: 'italic',
|
||||
mb: '8',
|
||||
})}
|
||||
>
|
||||
{subtitle.text}
|
||||
</p>
|
||||
|
||||
{/* Large Interactive Abacus */}
|
||||
<div
|
||||
className={css({
|
||||
transform: { base: 'scale(1.5)', md: 'scale(2)', lg: 'scale(2.5)' },
|
||||
transformOrigin: 'center center',
|
||||
mb: { base: '12', md: '16', lg: '20' },
|
||||
transition: 'all 0.5s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
})}
|
||||
>
|
||||
<AbacusReact
|
||||
value={abacusValue}
|
||||
columns={4}
|
||||
beadShape={appConfig.beadShape}
|
||||
showNumbers={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subtle hint to scroll */}
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.400',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
animation: 'bounce 2s ease-in-out infinite',
|
||||
})}
|
||||
>
|
||||
<span>Scroll to explore</span>
|
||||
<span>↓</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyframes for bounce animation */}
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@keyframes bounce {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.7;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import type { Subtitle } from '../data/abaciOneSubtitles'
|
||||
import { getRandomSubtitle, subtitles } from '../data/abaciOneSubtitles'
|
||||
|
||||
interface HomeHeroContextValue {
|
||||
subtitle: Subtitle
|
||||
abacusValue: number
|
||||
setAbacusValue: (value: number) => void
|
||||
isHeroVisible: boolean
|
||||
setIsHeroVisible: (visible: boolean) => void
|
||||
}
|
||||
|
||||
const HomeHeroContext = createContext<HomeHeroContextValue | null>(null)
|
||||
|
||||
export { HomeHeroContext }
|
||||
|
||||
export function HomeHeroProvider({ children }: { children: React.ReactNode }) {
|
||||
// Use first subtitle for SSR, then select random one on client mount
|
||||
const [subtitle, setSubtitle] = useState<Subtitle>(subtitles[0])
|
||||
|
||||
// Select random subtitle only on client side to avoid SSR mismatch
|
||||
useEffect(() => {
|
||||
setSubtitle(getRandomSubtitle())
|
||||
}, [])
|
||||
|
||||
// Shared abacus value (so it stays consistent during morph)
|
||||
const [abacusValue, setAbacusValue] = useState(1234)
|
||||
|
||||
// Track hero visibility for nav branding
|
||||
const [isHeroVisible, setIsHeroVisible] = useState(true)
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
subtitle,
|
||||
abacusValue,
|
||||
setAbacusValue,
|
||||
isHeroVisible,
|
||||
setIsHeroVisible,
|
||||
}),
|
||||
[subtitle, abacusValue, isHeroVisible]
|
||||
)
|
||||
|
||||
return <HomeHeroContext.Provider value={value}>{children}</HomeHeroContext.Provider>
|
||||
}
|
||||
|
||||
export function useHomeHero() {
|
||||
const context = useContext(HomeHeroContext)
|
||||
if (!context) {
|
||||
throw new Error('useHomeHero must be used within HomeHeroProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
Loading…
Reference in New Issue