Task 2 & 3: Set up project structure and build BootSequence component
- Task 2: Project structure and types already complete from Task 1 - Task 3: Create BootSequence component with Framer Motion - 14 boot lines with 220ms staggered delays - Terminal typing animation with opacity/translateY transitions - Blinking cursor using CSS animation - AnimatePresence for 800ms exit fade - Total boot time ~4.28s matching concept.html - Update App.tsx to use BootSequence with phase transitions
This commit is contained in:
@@ -79,11 +79,11 @@ src/
|
||||
|
||||
Run `npm create vite@latest . -- --template react-ts` to scaffold the project. Install dependencies: `npm install framer-motion lucide-react`. Initialize Tailwind: `npm install -D tailwindcss postcss autoprefixer && npx tailwindcss init -p`. Configure `tailwind.config.js` with custom colors (teal #00897B, coral #FF6B6B, etc.). Set up `src/index.css` with Tailwind directives and CSS custom properties matching concept.html.
|
||||
|
||||
- [ ] **Task 2: Set up project structure and types**
|
||||
- [x] **Task 2: Set up project structure and types**
|
||||
|
||||
Create the folder structure (`components/`, `hooks/`, `lib/`, `types/`). Define TypeScript interfaces in `types/index.ts` for: `Skill` (name, level, category, color), `Experience` (role, org, date, bullets), `Education` (degree, institution, period, detail), `Project` (title, description, link?). Create `lib/utils.ts` with helper function `calculateSkillOffset(level: number, radius: number): number` that returns `2 * Math.PI * radius * (1 - level / 100)`.
|
||||
|
||||
- [ ] **Task 3: Build BootSequence component**
|
||||
- [x] **Task 3: Build BootSequence component**
|
||||
|
||||
Create `components/BootSequence.tsx`. Implement terminal typing animation using Framer Motion or CSS transitions. Display boot lines with correct colors (cyan labels, green values, dim text). Use exact boot text from concept.html: "CLINICAL TERMINAL v3.2.1", "Initialising pharmacist profile...", SYSTEM/USER/ROLE/LOCATION, module loading, [OK] lines, READY. Duration: ~4 seconds. Emit `onComplete` callback when finished. Styling: black background, Fira Code font.
|
||||
|
||||
|
||||
@@ -46,3 +46,21 @@
|
||||
- **Learnings**:
|
||||
- Need `src/vite-env.d.ts` with `/// <reference types="vite/client" />` for CSS imports
|
||||
- Vite refuses to scaffold in non-empty directory, so manual setup was needed
|
||||
|
||||
### Iteration 2 — Task 2 & 3: Project structure and BootSequence
|
||||
- **Completed**: Task 2 (Set up project structure and types) - was already done in Task 1
|
||||
- **Completed**: Task 3 - Build BootSequence component
|
||||
- **Files created**:
|
||||
- `src/components/BootSequence.tsx` - Terminal typing animation using Framer Motion
|
||||
- **Design decisions**:
|
||||
- Used Framer Motion's `motion.div` with `initial`/`animate` props for line reveals
|
||||
- Each line animates with opacity 0→1, translateY 8px→0 over 400ms
|
||||
- Staggered delays calculated from cumulative 220ms per line
|
||||
- Blinking cursor implemented with CSS animation class `animate-blink`
|
||||
- Used `AnimatePresence` for smooth exit fade (800ms)
|
||||
- **Boot sequence timing preserved**: 14 lines × 220ms + 400ms pause + 800ms fade = ~4.28s
|
||||
- **Quality checks**: `npm run typecheck` ✓, `npm run build` ✓, `npm run lint` ✓
|
||||
- **Learnings**:
|
||||
- Framer Motion's delay prop uses seconds, not milliseconds
|
||||
- Used `dangerouslySetInnerHTML` for colored spans within boot lines (matches concept.html structure)
|
||||
- CSS classes for blink/seed-pulse animations already existed in index.css from Task 1
|
||||
|
||||
+8
-4
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import type { Phase } from './types'
|
||||
import { BootSequence } from './components/BootSequence'
|
||||
|
||||
function App() {
|
||||
const [phase, setPhase] = useState<Phase>('boot')
|
||||
@@ -7,13 +8,16 @@ function App() {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{phase === 'boot' && (
|
||||
<div className="fixed inset-0 bg-black flex flex-col justify-center p-10 font-mono text-sm">
|
||||
<div className="text-ecg-green">CLINICAL TERMINAL v3.2.1</div>
|
||||
<BootSequence onComplete={() => setPhase('ecg')} />
|
||||
)}
|
||||
|
||||
{phase === 'ecg' && (
|
||||
<div className="fixed inset-0 bg-black flex flex-col justify-center items-center">
|
||||
<button
|
||||
onClick={() => setPhase('content')}
|
||||
className="mt-8 text-ecg-cyan hover:text-ecg-green transition-colors"
|
||||
className="text-ecg-green font-mono hover:opacity-80 transition-opacity"
|
||||
>
|
||||
Press to skip boot sequence (placeholder)
|
||||
ECG Animation (placeholder - click to continue)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface BootLine {
|
||||
html: string
|
||||
delay: number
|
||||
}
|
||||
|
||||
const bootLines: BootLine[] = [
|
||||
{ html: '<span class="text-[#00ff41] font-bold">CLINICAL TERMINAL v3.2.1</span>', delay: 0 },
|
||||
{ html: '<span class="text-[#3a6b45]">Initialising pharmacist profile...</span>', delay: 220 },
|
||||
{ html: '<span class="text-[#3a6b45]">---</span>', delay: 220 },
|
||||
{ html: '<span class="text-[#00e5ff]">SYSTEM </span><span class="text-[#00ff41]">NHS Norfolk & Waveney ICB</span>', delay: 220 },
|
||||
{ html: '<span class="text-[#00e5ff]">USER </span><span class="text-[#00ff41]">Andy Charlwood</span>', delay: 220 },
|
||||
{ html: '<span class="text-[#00e5ff]">ROLE </span><span class="text-[#00ff41]">Deputy Head of Population Health & Data Analysis</span>', delay: 220 },
|
||||
{ html: '<span class="text-[#00e5ff]">LOCATION </span><span class="text-[#00ff41]">Norwich, UK</span>', delay: 220 },
|
||||
{ html: '<span class="text-[#3a6b45]">---</span>', delay: 220 },
|
||||
{ html: '<span class="text-[#3a6b45]">Loading modules...</span>', delay: 220 },
|
||||
{ html: '<span class="text-[#00ff41] font-bold">[OK]</span> <span class="text-[#3a6b45]">pharmacist_core.sys</span>', delay: 220 },
|
||||
{ html: '<span class="text-[#00ff41] font-bold">[OK]</span> <span class="text-[#3a6b45]">population_health.mod</span>', delay: 220 },
|
||||
{ html: '<span class="text-[#00ff41] font-bold">[OK]</span> <span class="text-[#3a6b45]">data_analytics.eng</span>', delay: 220 },
|
||||
{ html: '<span class="text-[#3a6b45]">---</span>', delay: 220 },
|
||||
{ html: '<span class="text-[#00ff41] font-bold">> READY — Rendering CV..<span class="ecg-seed-dot" id="ecg-seed-dot">.</span></span>', delay: 220 },
|
||||
]
|
||||
|
||||
interface BootSequenceProps {
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
export function BootSequence({ onComplete }: BootSequenceProps) {
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
const [lineDelays, setLineDelays] = useState<number[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const delays: number[] = []
|
||||
let totalDelay = 0
|
||||
bootLines.forEach((line) => {
|
||||
delays.push(totalDelay)
|
||||
totalDelay += line.delay
|
||||
})
|
||||
setLineDelays(delays)
|
||||
|
||||
const totalBootTime = totalDelay
|
||||
const fadeStartTime = totalBootTime + 400
|
||||
|
||||
const fadeTimer = setTimeout(() => {
|
||||
setIsVisible(false)
|
||||
}, fadeStartTime)
|
||||
|
||||
const completeTimer = setTimeout(() => {
|
||||
onComplete()
|
||||
}, fadeStartTime + 800)
|
||||
|
||||
return () => {
|
||||
clearTimeout(fadeTimer)
|
||||
clearTimeout(completeTimer)
|
||||
}
|
||||
}, [onComplete])
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50 flex flex-col justify-center bg-black p-10 font-mono text-sm overflow-hidden"
|
||||
initial={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.8, ease: 'easeOut' }}
|
||||
>
|
||||
<div className="flex flex-col gap-1 max-w-[640px]">
|
||||
{bootLines.map((line, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="whitespace-nowrap leading-relaxed"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
delay: lineDelays[index] / 1000,
|
||||
duration: 0.4,
|
||||
ease: 'easeOut',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: line.html }}
|
||||
/>
|
||||
))}
|
||||
<motion.div
|
||||
className="inline-block w-2 h-4 bg-[#00ff41] ml-1 animate-blink"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: lineDelays[lineDelays.length - 1] / 1000 }}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user