Skip to content

Commit 7b69bf2

Browse files
Copilotzealsprince
andcommitted
Add Interactive Tutorial Mode with guided coachmarks overlay
Co-authored-by: zealsprince <1859270+zealsprince@users.noreply.github.com>
1 parent 90cc968 commit 7b69bf2

5 files changed

Lines changed: 393 additions & 11 deletions

File tree

src/App.tsx

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { Button } from '@/components/ui/button'
33
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
44
import { BackgroundSwitcher } from '@/components/BackgroundSwitcher'
55
import { BG_THEMES, DEFAULT_THEME_ID, type BgTheme } from '@/lib/themes'
6+
import { TutorialProvider } from '@/components/TutorialProvider'
7+
import { TutorialOverlay } from '@/components/TutorialOverlay'
68

79
const STORAGE_KEY = 'roflbox-bg-theme'
810

@@ -80,12 +82,14 @@ function App() {
8082
}, [])
8183

8284
return (
83-
<div
84-
className={`min-h-screen flex flex-col items-center justify-start p-4 relative ${chaosClass('perspective-crazy')}`}
85-
style={{ overflow: 'visible', background: currentTheme.value }}
86-
>
87-
{/* Background theme switcher */}
88-
<BackgroundSwitcher currentThemeId={currentTheme.id} onThemeChange={handleThemeChange} />
85+
<TutorialProvider>
86+
<TutorialOverlay />
87+
<div
88+
className={`min-h-screen flex flex-col items-center justify-start p-4 relative ${chaosClass('perspective-crazy')}`}
89+
style={{ overflow: 'visible', background: currentTheme.value }}
90+
>
91+
{/* Background theme switcher */}
92+
<BackgroundSwitcher currentThemeId={currentTheme.id} onThemeChange={handleThemeChange} />
8993

9094
{/* FLOATING CHAOS - ABSOLUTE POSITIONED MADNESS - Only in chaos mode */}
9195
{chaosMode && (
@@ -171,7 +175,7 @@ function App() {
171175

172176
<div className="max-w-6xl mx-auto text-center">
173177
{/* Obnoxious Header - Conditional chaos */}
174-
<div className="mb-8 perspective-crazy">
178+
<div id="tutorial-hero" className="mb-8 perspective-crazy">
175179
<div className={`text-6xl md:text-9xl font-black mb-4 transition-all duration-300 ${chaosClass('hover-fly animate-wobble-crazy')} ${isBlinking ? 'text-red-500 scale-110 animate-shake-violent' : 'text-transparent bg-clip-text bg-gradient-to-r from-lime-400 via-yellow-300 to-red-500'}`}>
176180
<span className={`font-mono tracking-wider drop-shadow-lg ${chaosClass('hover-explode rotate-3d-crazy')}`} style={{fontFamily: 'Comic Sans MS, cursive'}}>R</span>
177181
<span className={`font-serif italic ${chaosClass('animate-rainbow-spin')}`}>O</span>
@@ -223,7 +227,7 @@ function App() {
223227
</div>
224228

225229
{/* Fake Hacking Terminal - Conditional chaos */}
226-
<Card className={`max-w-2xl mx-auto mb-8 bg-black border-2 border-green-400 ${chaosClass('animate-wobble-crazy hover-explode rotate-3d-crazy')}`} style={{transform: chaosMode ? 'perspective(400px) rotateY(10deg)' : 'none'}}>
230+
<Card id="tutorial-terminal" className={`max-w-2xl mx-auto mb-8 bg-black border-2 border-green-400 ${chaosClass('animate-wobble-crazy hover-explode rotate-3d-crazy')}`} style={{transform: chaosMode ? 'perspective(400px) rotateY(10deg)' : 'none'}}>
227231
<CardHeader className={chaosClass('animate-shake-violent')}>
228232
<CardTitle className={`text-green-400 font-mono ${chaosClass('animate-text-crazy hover-fly')}`} style={{fontFamily: 'Comic Sans MS, cursive'}}>
229233
🖥️ ELITE HACKER TERMINAL 9000™ 🖥️
@@ -246,7 +250,7 @@ function App() {
246250
</Card>
247251

248252
{/* Ridiculous Counter - Conditional chaos */}
249-
<Card className={`max-w-md mx-auto mb-8 border-4 border-rainbow animate-pulse ${chaosClass('hover-explode rotate-3d-crazy animate-float-diagonal')}`} style={{background: 'linear-gradient(45deg, #ff00ff, #00ff00, #ff8000, #ffff00)', transform: chaosMode ? 'skew(-5deg) perspective(200px) rotateX(15deg)' : 'skew(-2deg)'}}>
253+
<Card id="tutorial-counter" className={`max-w-md mx-auto mb-8 border-4 border-rainbow animate-pulse ${chaosClass('hover-explode rotate-3d-crazy animate-float-diagonal')}`} style={{background: 'linear-gradient(45deg, #ff00ff, #00ff00, #ff8000, #ffff00)', transform: chaosMode ? 'skew(-5deg) perspective(200px) rotateX(15deg)' : 'skew(-2deg)'}}>
250254
<CardHeader className={chaosClass('animate-shake-violent')}>
251255
<CardTitle className={`text-2xl font-mono text-red-500 ${chaosClass('animate-blink-seizure hover-fly')}`} style={{fontFamily: 'Comic Sans MS, cursive', textShadow: '0 0 10px #ff0000, 0 0 20px #ff0000'}}>
252256
💥 SKILL COUNTER 3000™ 💥
@@ -293,7 +297,7 @@ function App() {
293297
</Card>
294298

295299
{/* Fake Features - Conditional chaos */}
296-
<div className="grid md:grid-cols-3 gap-6 mb-8 perspective-crazy">
300+
<div id="tutorial-features" className="grid md:grid-cols-3 gap-6 mb-8 perspective-crazy">
297301
<Card className={`border-2 border-red-500 bg-red-950/50 transform rotate-1 ${chaosClass('hover-explode animate-wobble-crazy')}`} style={{overflow: 'visible', boxShadow: '0 0 30px #ff0000'}}>
298302
<CardHeader className={chaosClass('animate-shake-violent')}>
299303
<CardTitle className={`text-red-400 font-mono ${chaosClass('animate-blink-seizure hover-fly')}`} style={{fontFamily: 'Comic Sans MS, cursive', fontSize: '1.5em'}}>🔥 AIMBOT 9000 🔥</CardTitle>
@@ -323,7 +327,7 @@ function App() {
323327
</div>
324328

325329
{/* Testimonials - Conditional chaos */}
326-
<Card className={`mb-8 border-4 border-dashed border-pink-500 bg-gradient-to-r from-pink-950/50 to-blue-950/50 ${chaosClass('animate-wobble-crazy hover-explode rotate-3d-crazy')}`} style={{overflow: 'visible', transform: chaosMode ? 'perspective(300px) rotateY(-10deg) skew(5deg)' : 'skew(2deg)'}}>
330+
<Card id="tutorial-testimonials" className={`mb-8 border-4 border-dashed border-pink-500 bg-gradient-to-r from-pink-950/50 to-blue-950/50 ${chaosClass('animate-wobble-crazy hover-explode rotate-3d-crazy')}`} style={{overflow: 'visible', transform: chaosMode ? 'perspective(300px) rotateY(-10deg) skew(5deg)' : 'skew(2deg)'}}>
327331
<CardHeader className={chaosClass('animate-shake-violent')}>
328332
<CardTitle className={`text-3xl font-mono text-pink-400 ${chaosClass('animate-text-crazy hover-fly')}`} style={{fontFamily: 'Comic Sans MS, cursive', textShadow: '0 0 15px #ff69b4'}}>
329333
💬 TESTIMONIALS FROM PROS 💬
@@ -357,6 +361,7 @@ function App() {
357361
)}
358362

359363
</div>
364+
</TutorialProvider>
360365
)
361366
}
362367

src/components/BackgroundSwitcher.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useState } from 'react'
22
import { Button } from '@/components/ui/button'
33
import { BG_THEMES, type BgTheme } from '@/lib/themes'
4+
import { useTutorial } from '@/lib/tutorial'
45

56
interface BackgroundSwitcherProps {
67
currentThemeId: string
@@ -9,6 +10,7 @@ interface BackgroundSwitcherProps {
910

1011
export function BackgroundSwitcher({ currentThemeId, onThemeChange }: BackgroundSwitcherProps) {
1112
const [open, setOpen] = useState(false)
13+
const { start: startTutorial, completed: tutorialCompleted } = useTutorial()
1214

1315
return (
1416
<div className="fixed top-3 right-3 z-50 flex flex-col items-end gap-2">
@@ -61,6 +63,17 @@ export function BackgroundSwitcher({ currentThemeId, onThemeChange }: Background
6163
))}
6264
</div>
6365
<div className="mt-2 pt-2 border-t border-white/10">
66+
<button
67+
onClick={() => {
68+
setOpen(false)
69+
startTutorial()
70+
}}
71+
className="text-green-400/80 text-xs font-mono hover:text-green-300 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-green-400/50 w-full text-left py-0.5 transition-colors"
72+
>
73+
{tutorialCompleted ? '▶ Tutorial (replay)' : '▶ Start tutorial'}
74+
</button>
75+
</div>
76+
<div className="mt-1 pt-1 border-t border-white/10">
6477
<button
6578
onClick={() => setOpen(false)}
6679
className="text-white/40 text-xs font-mono hover:text-white/70 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-white/50 w-full text-right"

src/components/TutorialOverlay.tsx

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { useEffect, useRef, useState, useCallback } from 'react'
2+
import { useTutorial } from '@/lib/tutorial'
3+
4+
interface HighlightRect {
5+
top: number
6+
left: number
7+
width: number
8+
height: number
9+
}
10+
11+
const PADDING = 10
12+
const CALLOUT_WIDTH = 340
13+
const CALLOUT_EST_HEIGHT = 210
14+
15+
export function TutorialOverlay() {
16+
const { isActive, currentStep, steps, next, back, close, finish } = useTutorial()
17+
const step = steps[currentStep]
18+
const isFirst = currentStep === 0
19+
const isLast = currentStep === steps.length - 1
20+
21+
const [rect, setRect] = useState<HighlightRect | null>(null)
22+
const dialogRef = useRef<HTMLDivElement>(null)
23+
const firstFocusableRef = useRef<HTMLButtonElement>(null)
24+
25+
const prefersReducedMotion =
26+
typeof window !== 'undefined' &&
27+
window.matchMedia('(prefers-reduced-motion: reduce)').matches
28+
29+
const measureTarget = useCallback(() => {
30+
if (!step) return
31+
const el = document.querySelector(step.targetSelector)
32+
if (!el) {
33+
setRect(null)
34+
return
35+
}
36+
const r = el.getBoundingClientRect()
37+
setRect({ top: r.top, left: r.left, width: r.width, height: r.height })
38+
}, [step])
39+
40+
// Scroll to element and measure on step change
41+
useEffect(() => {
42+
if (!isActive || !step) return
43+
const el = document.querySelector(step.targetSelector)
44+
if (el) {
45+
el.scrollIntoView({
46+
behavior: prefersReducedMotion ? 'instant' : 'smooth',
47+
block: 'center',
48+
})
49+
}
50+
// Measure after scroll settles
51+
const timeout = setTimeout(measureTarget, prefersReducedMotion ? 0 : 400)
52+
return () => clearTimeout(timeout)
53+
}, [isActive, currentStep, step, prefersReducedMotion, measureTarget])
54+
55+
// Re-measure on resize / scroll
56+
useEffect(() => {
57+
if (!isActive) return
58+
window.addEventListener('resize', measureTarget)
59+
window.addEventListener('scroll', measureTarget, { passive: true })
60+
return () => {
61+
window.removeEventListener('resize', measureTarget)
62+
window.removeEventListener('scroll', measureTarget)
63+
}
64+
}, [isActive, measureTarget])
65+
66+
// Keyboard: Esc → close
67+
useEffect(() => {
68+
if (!isActive) return
69+
const handler = (e: KeyboardEvent) => {
70+
if (e.key === 'Escape') {
71+
e.preventDefault()
72+
close()
73+
}
74+
}
75+
document.addEventListener('keydown', handler)
76+
return () => document.removeEventListener('keydown', handler)
77+
}, [isActive, close])
78+
79+
// Focus trap within dialog
80+
useEffect(() => {
81+
if (!isActive || !dialogRef.current) return
82+
const el = dialogRef.current
83+
const focusables = Array.from(
84+
el.querySelectorAll<HTMLElement>('button, [href], input, [tabindex]:not([tabindex="-1"])')
85+
)
86+
const first = focusables[0]
87+
const last = focusables[focusables.length - 1]
88+
89+
const handleTab = (e: KeyboardEvent) => {
90+
if (e.key !== 'Tab') return
91+
if (focusables.length === 0) {
92+
e.preventDefault()
93+
return
94+
}
95+
if (e.shiftKey) {
96+
if (document.activeElement === first) {
97+
e.preventDefault()
98+
last?.focus()
99+
}
100+
} else {
101+
if (document.activeElement === last) {
102+
e.preventDefault()
103+
first?.focus()
104+
}
105+
}
106+
}
107+
108+
document.addEventListener('keydown', handleTab)
109+
return () => document.removeEventListener('keydown', handleTab)
110+
}, [isActive, currentStep])
111+
112+
// Move focus into dialog when step changes
113+
useEffect(() => {
114+
if (isActive && firstFocusableRef.current) {
115+
firstFocusableRef.current.focus()
116+
}
117+
}, [isActive, currentStep])
118+
119+
if (!isActive || !step) return null
120+
121+
// --- Spotlight geometry ---
122+
const hlTop = rect ? rect.top - PADDING : 0
123+
const hlLeft = rect ? rect.left - PADDING : 0
124+
const hlWidth = rect ? rect.width + PADDING * 2 : 0
125+
const hlHeight = rect ? rect.height + PADDING * 2 : 0
126+
127+
// --- Callout position ---
128+
const vw = window.innerWidth
129+
const vh = window.innerHeight
130+
const cw = Math.min(CALLOUT_WIDTH, vw - 32)
131+
let calloutTop: number
132+
let calloutLeft: number
133+
134+
if (rect) {
135+
calloutLeft = Math.max(16, Math.min(vw - cw - 16, hlLeft + hlWidth / 2 - cw / 2))
136+
const spaceBelow = vh - (hlTop + hlHeight)
137+
if (spaceBelow >= CALLOUT_EST_HEIGHT + 16) {
138+
calloutTop = hlTop + hlHeight + 12
139+
} else {
140+
calloutTop = Math.max(16, hlTop - CALLOUT_EST_HEIGHT - 12)
141+
}
142+
} else {
143+
calloutTop = vh / 2 - CALLOUT_EST_HEIGHT / 2
144+
calloutLeft = vw / 2 - cw / 2
145+
}
146+
147+
return (
148+
<>
149+
{/* Dim backdrop via box-shadow on spotlight box */}
150+
<div
151+
aria-hidden="true"
152+
style={{
153+
position: 'fixed',
154+
top: rect ? hlTop : 0,
155+
left: rect ? hlLeft : 0,
156+
width: rect ? hlWidth : '100%',
157+
height: rect ? hlHeight : '100%',
158+
borderRadius: 8,
159+
boxShadow: '0 0 0 9999px rgba(0,0,0,0.78)',
160+
border: rect ? '2px solid #00ff41' : 'none',
161+
zIndex: 9998,
162+
pointerEvents: 'none',
163+
}}
164+
/>
165+
166+
{/* Callout dialog */}
167+
<div
168+
ref={dialogRef}
169+
role="dialog"
170+
aria-modal="true"
171+
aria-label={`Tutorial step ${currentStep + 1} of ${steps.length}: ${step.title}`}
172+
style={{
173+
position: 'fixed',
174+
top: calloutTop,
175+
left: calloutLeft,
176+
width: cw,
177+
zIndex: 9999,
178+
outline: 'none',
179+
}}
180+
className="bg-black border-2 border-green-400 rounded-lg p-4 font-mono shadow-2xl shadow-green-900/50"
181+
>
182+
{/* Step indicator */}
183+
<div className="text-green-500/60 text-xs mb-1 tracking-wider">
184+
STEP {currentStep + 1} / {steps.length}
185+
</div>
186+
187+
<h2 className="text-green-400 font-bold text-sm mb-2 leading-snug">
188+
{step.title}
189+
</h2>
190+
191+
<p className="text-green-300/80 text-xs mb-3 leading-relaxed">
192+
{step.body}
193+
</p>
194+
195+
{/* Progress dots */}
196+
<div className="flex gap-1.5 mb-3" aria-hidden="true">
197+
{steps.map((_, i) => (
198+
<div
199+
key={i}
200+
className={`h-1.5 rounded-full ${
201+
i === currentStep
202+
? 'bg-green-400 w-4'
203+
: i < currentStep
204+
? 'bg-green-700 w-1.5'
205+
: 'bg-white/20 w-1.5'
206+
}`}
207+
/>
208+
))}
209+
</div>
210+
211+
{/* Controls */}
212+
<div className="flex items-center gap-2 flex-wrap">
213+
{/* Skip / Close */}
214+
<button
215+
ref={firstFocusableRef}
216+
onClick={close}
217+
className="text-xs text-white/40 hover:text-white/70 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-white/50 px-1 py-1 rounded transition-colors"
218+
>
219+
[skip]
220+
</button>
221+
222+
<div className="flex gap-2 ml-auto">
223+
{!isFirst && (
224+
<button
225+
onClick={back}
226+
className="text-xs px-3 py-1.5 rounded border border-white/20 text-white/70 hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-white/50 transition-colors"
227+
>
228+
← Back
229+
</button>
230+
)}
231+
{!isLast ? (
232+
<button
233+
onClick={next}
234+
className="text-xs px-3 py-1.5 rounded bg-green-700 hover:bg-green-600 text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-green-400 transition-colors font-bold"
235+
>
236+
Next →
237+
</button>
238+
) : (
239+
<button
240+
onClick={finish}
241+
className="text-xs px-3 py-1.5 rounded bg-green-700 hover:bg-green-600 text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-green-400 transition-colors font-bold"
242+
>
243+
Finish ✓
244+
</button>
245+
)}
246+
</div>
247+
</div>
248+
</div>
249+
</>
250+
)
251+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { type ReactNode } from 'react'
2+
import { TutorialContext, useTutorialState } from '@/lib/tutorial'
3+
4+
export function TutorialProvider({ children }: { children: ReactNode }) {
5+
const value = useTutorialState()
6+
return (
7+
<TutorialContext.Provider value={value}>
8+
{children}
9+
</TutorialContext.Provider>
10+
)
11+
}

0 commit comments

Comments
 (0)