Function bodies 269 total
LessonPage function · typescript · L24-L323 (300 LOC)src/app/lesson/[id]/page.tsx
export default function LessonPage() {
const router = useRouter();
const params = useParams();
const lessonId = params.id as string;
const { activeChild } = useActiveChild();
const [sessionId, setSessionId] = useState<string | null>(null);
const [currentPhase, setCurrentPhase] = useState<Phase>("instruction");
const [messages, setMessages] = useState<Message[]>([]);
const [lessonData, setLessonData] = useState<LessonDetailResponse | null>(null);
const [assessmentResult, setAssessmentResult] = useState<AssessmentResult | null>(null);
const [submittedText, setSubmittedText] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [transition, setTransition] = useState<"instruction" | "guided" | null>(null);
const [newBadges, setNewBadges] = useState<string[]>([]);
const [isCompletedReview, setIsCompletedReview] = useState(false);
consinit function · typescript · L52-L85 (34 LOC)src/app/lesson/[id]/page.tsx
async function init() {
try {
const [detail, session] = await Promise.all([
getLessonDetail(lessonId),
startLesson(activeChild!.id, lessonId),
]);
if (cancelled) return;
setLessonData(detail);
setSessionId(session.sessionId);
// If lesson was already completed, show the feedback summary
const sessionAny = session as any;
if (sessionAny.completed && sessionAny.assessment) {
setAssessmentResult(sessionAny.assessment);
setSubmittedText(sessionAny.submittedText ?? "");
setCurrentPhase("feedback");
setIsCompletedReview(true);
} else if (session.resumed) {
setMessages(session.conversationHistory);
setCurrentPhase(session.phase);
} else {
setMessages([session.initialPrompt]);
setCurrentPhase("instruction");
}
} catch (err) {
if (!cancelled) {
setError(err instanceof ErrorcountWords function · typescript · L24-L26 (3 LOC)src/app/placement/[childId]/page.tsx
function countWords(text: string): number {
return text.trim().split(/\s+/).filter(Boolean).length;
}PlacementAssessmentPage function · typescript · L30-L374 (345 LOC)src/app/placement/[childId]/page.tsx
export default function PlacementAssessmentPage() {
const { childId } = useParams<{ childId: string }>();
const router = useRouter();
const { status } = useSession();
// Preserved state from original implementation
const [child, setChild] = useState<ChildInfo | null>(null);
const [prompts, setPrompts] = useState<string[]>([]);
const [responses, setResponses] = useState<string[]>(["", "", ""]);
const [step, setStep] = useState(0);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
// New quest UI state
const [showIntro, setShowIntro] = useState(true);
const [completedTrials, setCompletedTrials] = useState<Set<number>>(
new Set()
);
const [showCelebration, setShowCelebration] = useState(false);
const [showFinale, setShowFinale] = useState(false);
const [cardTransitioning, setChandleResponseChange function · typescript · L191-L198 (8 LOC)src/app/placement/[childId]/page.tsx
function handleResponseChange(value: string) {
setResponses((prev) => {
const updated = [...prev];
updated[step] = value;
return updated;
});
debouncedSave();
}handleSubmitToAPI function · typescript · L200-L228 (29 LOC)src/app/placement/[childId]/page.tsx
async function handleSubmitToAPI() {
// Flush any pending save
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
setSubmitting(true);
setError(null);
try {
const res = await fetch("/api/placement/submit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ childId, prompts, responses }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to submit assessment");
}
router.push(`/placement/${childId}/results`);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
setSubmitting(false);
setShowFinale(false);
}
}handleTrialSubmit function · typescript · L230-L238 (9 LOC)src/app/placement/[childId]/page.tsx
function handleTrialSubmit() {
const currentStep = step;
// Mark trial as completed
setCompletedTrials((prev) => new Set(prev).add(currentStep));
// Show celebration
setShowCelebration(true);
}Powered by Repobility — scan your code at https://repobility.com
handleTierOverride function · typescript · L90-L111 (22 LOC)src/app/placement/[childId]/results/page.tsx
async function handleTierOverride(tier: number) {
setOverriding(true);
setError(null);
try {
const res = await fetch(`/api/placement/${childId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ assignedTier: tier }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to update tier");
}
router.push(`/curriculum/${childId}/setup`);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
setOverriding(false);
}
}handleAccept function · typescript · L113-L115 (3 LOC)src/app/placement/[childId]/results/page.tsx
function handleAccept() {
router.push(`/curriculum/${childId}/setup`);
}ScoreBadge function · typescript · L29-L43 (15 LOC)src/app/portfolio/[childId]/page.tsx
function ScoreBadge({ score }: { score: number }) {
let colorClass = "bg-orange-100 text-orange-700";
if (score >= 3.5) {
colorClass = "bg-green-100 text-green-700";
} else if (score >= 2.5) {
colorClass = "bg-amber-100 text-amber-700";
}
return (
<span
className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold ${colorClass}`}
>
{score.toFixed(1)}/4.0
</span>
);
}SubmissionCard function · typescript · L45-L174 (130 LOC)src/app/portfolio/[childId]/page.tsx
function SubmissionCard({ submission }: { submission: PortfolioSubmission }) {
const [expanded, setExpanded] = useState(false);
return (
<div className="bg-white rounded-2xl shadow-sm border border-[#FF6B6B]/10 overflow-hidden transition-all duration-200">
<button
onClick={() => setExpanded(!expanded)}
className="w-full text-left p-5 hover:bg-[#FFF9F0]/50 transition-colors"
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 flex-1 min-w-0">
<span className="text-2xl flex-shrink-0">
{TYPE_ICONS[submission.lessonType] || "\uD83D\uDCC4"}
</span>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-sm font-bold text-[#2D3436] truncate">
{submission.lessonTitle}
</h3>
{submission.revisionNumber > 0 && (
PortfolioPage function · typescript · L176-L366 (191 LOC)src/app/portfolio/[childId]/page.tsx
export default function PortfolioPage() {
const { childId } = useParams<{ childId: string }>();
const router = useRouter();
const [submissions, setSubmissions] = useState<PortfolioSubmission[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeFilter, setActiveFilter] = useState("all");
const [sort, setSort] = useState("newest");
const limit = 10;
const fetchPortfolio = useCallback(
async (pageNum: number, append: boolean) => {
if (!childId) return;
try {
if (append) {
setLoadingMore(true);
} else {
setLoading(true);
}
const typeParam =
activeFilter === "all" ? undefined : activeFilter;
const data = await getPortfolio(childId, {
page: pageNum,
limit,
AssessmentPhase function · typescript · L31-L226 (196 LOC)src/components/AssessmentPhase.tsx
export default function AssessmentPhase({
lessonTitle,
onSubmit,
submitting = false,
qualityError,
rubric,
}: AssessmentPhaseProps) {
const { coachName } = useTier();
const [writingText, setWritingText] = useState("");
const [showConfirm, setShowConfirm] = useState(false);
const [checkedItems, setCheckedItems] = useState<Record<number, boolean>>({});
// Use rubric data if available, otherwise fall back to defaults
const requirements =
rubric?.criteria.map((c) => c.displayName) || defaultTask.requirements;
const wordRange = rubric
? { min: rubric.wordRange[0], max: rubric.wordRange[1] }
: defaultTask.wordRange;
const wordCount = writingText
.trim()
.split(/\s+/)
.filter((w) => w.length > 0).length;
const canSubmit = wordCount >= wordRange.min;
const toggleCheck = (index: number) => {
setCheckedItems((prev) => ({ ...prev, [index]: !prev[index] }));
};
// Natural-language word count message
const wordCountMessage = (() =CelebrationOverlay function · typescript · L11-L120 (110 LOC)src/components/CelebrationOverlay.tsx
export default function CelebrationOverlay({
badges,
onDismiss,
}: CelebrationOverlayProps) {
const [visible, setVisible] = useState(false);
useEffect(() => {
// Fire confetti on mount
confetti({
particleCount: 100,
spread: 70,
origin: { y: 0.6 },
});
// Second burst for extra celebration
const timer = setTimeout(() => {
confetti({
particleCount: 50,
spread: 100,
origin: { y: 0.5 },
});
}, 300);
// Trigger entrance animation
requestAnimationFrame(() => setVisible(true));
return () => clearTimeout(timer);
}, []);
const handleDismiss = () => {
setVisible(false);
setTimeout(onDismiss, 200);
};
return (
<div
className={`fixed inset-0 z-50 flex items-center justify-center p-4 transition-all duration-300 ${
visible ? "bg-black/50" : "bg-black/0"
}`}
onClick={handleDismiss}
>
<div
className={`bg-white rounded-3xl shadow-2xl maxActivityHeatmap function · typescript · L16-L202 (187 LOC)src/components/charts/ActivityHeatmap.tsx
export default function ActivityHeatmap({ data }: ActivityHeatmapProps) {
const [primaryColor, setPrimaryColor] = useState("#FF6B6B");
useEffect(() => {
const color = getComputedStyle(document.documentElement)
.getPropertyValue("--color-active-primary")
.trim();
if (color) setPrimaryColor(color);
}, []);
// Build a map of date string -> count
const countMap = useMemo(() => {
const map = new Map<string, number>();
for (const item of data) {
map.set(item.date, item.count);
}
return map;
}, [data]);
// Generate grid: last ~90 days aligned to week boundaries
const { weeks, monthLabels } = useMemo(() => {
const today = new Date();
const start = startOfWeek(subDays(today, 89), { weekStartsOn: 0 });
const allDays = eachDayOfInterval({ start, end: today });
// Group into weeks (columns)
const weeksArr: { date: Date; dateStr: string; count: number }[][] = [];
let currentWeek: { date: Date; dateStr: string; couRepobility · MCP-ready · https://repobility.com
getCellColor function · typescript · L78-L83 (6 LOC)src/components/charts/ActivityHeatmap.tsx
function getCellColor(count: number): string {
if (count === 0) return "#f3f4f6"; // gray-100
if (count === 1) return `${primaryColor}40`; // 25% opacity
if (count === 2) return `${primaryColor}80`; // 50% opacity
return `${primaryColor}cc`; // 80% opacity for 3+
}capitalize function · typescript · L26-L28 (3 LOC)src/components/charts/ScoreTrendChart.tsx
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}ScoreTrendChart function · typescript · L30-L114 (85 LOC)src/components/charts/ScoreTrendChart.tsx
export default function ScoreTrendChart({ data }: ScoreTrendChartProps) {
const [primaryColor, setPrimaryColor] = useState("#FF6B6B");
useEffect(() => {
const color = getComputedStyle(document.documentElement)
.getPropertyValue("--color-active-primary")
.trim();
if (color) setPrimaryColor(color);
}, []);
if (!data || data.length === 0) {
return (
<div className="bg-white rounded-2xl p-6 shadow-sm border border-active-primary/10 flex flex-col items-center justify-center min-h-[240px]">
<span className="text-4xl mb-3">{"📊"}</span>
<p className="text-sm font-semibold text-active-text/60 text-center">
Complete an assessment to see score trends!
</p>
</div>
);
}
const chartData = data.map((d) => ({
...d,
label: capitalize(d.type),
subtitle: `${d.count} ${d.count === 1 ? "lesson" : "lessons"}`,
}));
return (
<div className="bg-white rounded-2xl p-5 shadow-sm border border-active-primstripLeadingMascot function · typescript · L106-L108 (3 LOC)src/components/CoachMessage.tsx
function stripLeadingMascot(text: string): string {
return text.replace(/^(?:🦉|🦊|🐺)\s*/, "");
}CoachMessage function · typescript · L110-L136 (27 LOC)src/components/CoachMessage.tsx
export default function CoachMessage({ content, isNew, onTypingComplete, completeRef }: CoachMessageProps) {
const cleaned = stripLeadingMascot(content);
const { displayedText, isTyping, complete } = useTypingEffect(cleaned, !!isNew);
// Expose complete() so parent can instant-finish the animation
useEffect(() => {
if (completeRef) completeRef.current = complete;
}, [complete, completeRef]);
// Notify parent when typing finishes
useEffect(() => {
if (!isTyping && displayedText === cleaned && onTypingComplete) {
onTypingComplete();
}
}, [isTyping, displayedText, cleaned, onTypingComplete]);
return (
<div className="coach-message text-[0.95rem] leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{displayedText}
</ReactMarkdown>
{isTyping && (
<span className="inline-block w-[2px] h-[1em] bg-active-text/40 animate-pulse align-text-bottom ml-0.5" />
)}
</div>
)StarRating function · typescript · L21-L69 (49 LOC)src/components/FeedbackView.tsx
function StarRating({
score,
maxScore = 4,
size = "lg",
animate = false,
}: {
score: number;
maxScore?: number;
size?: "sm" | "lg";
animate?: boolean;
}) {
// Round to nearest 0.5
const rounded = Math.round(score * 2) / 2;
const starSize = size === "lg" ? "text-3xl" : "text-base";
return (
<span className="inline-flex items-center gap-0.5">
{Array.from({ length: maxScore }, (_, i) => {
const isFull = i < Math.floor(rounded);
const isHalf = !isFull && i < rounded;
return (
<span
key={i}
className={`${starSize} ${
animate ? `animate-star-pop stagger-${i + 1}` : ""
}`}
>
{isFull ? (
"\u2B50"
) : isHalf ? (
<span className="relative inline-block">
<span className="text-inherit opacity-30">{"\u2B50"}</span>
<span
className="absolute inset-0 overflow-hidden"
FeedbackCard function · typescript · L71-L102 (32 LOC)src/components/FeedbackView.tsx
function FeedbackCard({
icon,
title,
text,
variant,
}: {
icon: string;
title: string;
text: string;
variant: "success" | "growth";
}) {
const styles = {
success:
"from-active-secondary/10 to-active-secondary/5 border-active-secondary/20",
growth: "from-active-accent/10 to-active-accent/5 border-active-accent/20",
};
const titleColors = {
success: "text-active-secondary",
growth: "text-active-text",
};
return (
<div className={`bg-gradient-to-br ${styles[variant]} rounded-2xl p-5 border`}>
<h3
className={`font-bold ${titleColors[variant]} mb-2 flex items-center gap-2`}
>
<span className="text-xl">{icon}</span> {title}
</h3>
<p className="text-active-text/80 text-[15px] leading-relaxed">{text}</p>
</div>
);
}ScoreComparison function · typescript · L104-L138 (35 LOC)src/components/FeedbackView.tsx
function ScoreComparison({
criterion,
previous,
current,
}: {
criterion: string;
previous: number;
current: number;
}) {
const diff = current - previous;
const improved = diff > 0;
const same = diff === 0;
return (
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-semibold text-active-text capitalize flex-shrink-0">
{criterion.replace(/_/g, " ")}
</span>
<div className="flex items-center gap-2">
<StarRating score={previous} size="sm" />
<span className="text-active-text/40">{"\u2192"}</span>
<StarRating score={current} size="sm" />
{!same && (
<span
className={`text-xs font-bold ${
improved ? "text-green-500" : "text-active-primary"
}`}
>
{improved ? `+${diff.toFixed(1)}` : diff.toFixed(1)}
</span>
)}
</div>
</div>
);
}All rows above produced by Repobility · https://repobility.com
parseWritingPrompt function · typescript · L44-L47 (4 LOC)src/components/GuidedPracticePhase.tsx
function parseWritingPrompt(content: string): {
text: string;
writingPrompt: string | null;
} {parseExpectsResponse function · typescript · L67-L70 (4 LOC)src/components/GuidedPracticePhase.tsx
function parseExpectsResponse(content: string): {
text: string;
expectsResponse: boolean;
} {generateId function · typescript · L78-L80 (3 LOC)src/components/GuidedPracticePhase.tsx
function generateId(): string {
return `item-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}WritingCardCompleted function · typescript · L104-L138 (35 LOC)src/components/GuidedPracticePhase.tsx
function WritingCardCompleted({
prompt,
answer,
}: {
prompt: string;
answer: string;
}) {
return (
<div className="w-full rounded-2xl bg-[#e6f9f3] border-2 border-[#00b894] p-5 animate-fade-in">
<div className="text-[0.82rem] font-extrabold uppercase tracking-wider text-[#00b894] mb-2 flex items-center gap-1">
✏️ Your turn to write!
<span className="bg-[#00b894] text-white px-2 py-0.5 rounded-[10px] text-[0.72rem] font-bold ml-1.5 normal-case tracking-normal">
Completed
</span>
</div>
<div className="text-active-text font-semibold text-[0.95rem] leading-relaxed mb-3.5">
{prompt}
</div>
<textarea
readOnly
className="w-full writing-area writing-lined min-h-[80px] px-3.5 py-3 rounded-lg border-[1.5px] border-[#00b894] bg-[#f0faf5] text-[0.95rem] leading-[30px] text-active-text resize-vertical outline-none cursor-default"
value={answer}
/>
<div className="flex justify-eLockedCard function · typescript · L192-L201 (10 LOC)src/components/GuidedPracticePhase.tsx
function LockedCard() {
return (
<div className="w-full rounded-2xl bg-[#f5f3ef] border-2 border-dashed border-[#d5d0c8] opacity-70 p-5 animate-fade-in">
<div className="text-center py-3 text-active-text/50 font-semibold text-[0.9rem]">
<span className="text-[1.3rem] block mb-1">🔒</span>
One more writing challenge coming up!
</div>
</div>
);
}StageDivider function · typescript · L203-L216 (14 LOC)src/components/GuidedPracticePhase.tsx
function StageDivider({ stage, label }: { stage: number; label: string }) {
return (
<div className="flex items-center gap-3 py-3" role="separator">
<div className="flex-1 h-px bg-active-secondary/20" />
<span className="text-[0.72rem] font-bold uppercase tracking-wider text-active-secondary/60 whitespace-nowrap flex items-center gap-1.5">
<span className="w-5 h-5 rounded-full bg-active-secondary/15 text-active-secondary flex items-center justify-center text-[0.6rem] font-extrabold">
{stage}
</span>
Stage {stage}: {label}
</span>
<div className="flex-1 h-px bg-active-secondary/20" />
</div>
);
}BottomProgressBar function · typescript · L218-L242 (25 LOC)src/components/GuidedPracticePhase.tsx
function BottomProgressBar({ currentStage, practiceComplete }: { currentStage: number; practiceComplete: boolean }) {
const totalStages = 3;
const pct = practiceComplete ? 100 : Math.min(((currentStage - 1) / totalStages) * 100 + 10, 95);
return (
<div className="fixed bottom-0 left-0 right-0 z-30 bg-white border-t border-[#e0dcd5] shadow-[0_-2px_12px_rgba(0,0,0,0.05)]">
<div className="max-w-[640px] mx-auto px-4 py-3">
<div className="flex items-center justify-center gap-1.5 text-[0.78rem] font-bold text-active-text/50">
<span>✏️</span>
<span>
{practiceComplete
? "Practice complete!"
: `Stage ${currentStage}: ${STAGE_LABELS[currentStage] ?? "Practice"}`}
</span>
<div className="flex-1 max-w-[140px] h-1.5 bg-[#eae6df] rounded-full overflow-hidden">
<div
className="h-full bg-active-primary rounded-full transition-all duration-500"
generateId function · typescript · L51-L53 (3 LOC)src/components/InstructionPhase.tsx
function generateId(): string {
return `item-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}Repobility — same analyzer, your code, free for public repos · /scan/
parseExpectsResponse function · typescript · L56-L59 (4 LOC)src/components/InstructionPhase.tsx
function parseExpectsResponse(content: string): {
text: string;
expectsResponse: boolean;
} {stripWritingPrompt function · typescript · L68-L73 (6 LOC)src/components/InstructionPhase.tsx
function stripWritingPrompt(content: string): string {
return content
.replace(/\[WRITING_PROMPT:\s*"[\s\S]*?"\]\s*/gi, "")
.replace(/\[WRITING_PROMPT:\s*[^\]]+?\]\s*/gi, "")
.trim();
}StepProgressBar function · typescript · L78-L126 (49 LOC)src/components/InstructionPhase.tsx
function StepProgressBar({ currentStep }: { currentStep: number }) {
return (
<div className="flex flex-col items-center gap-1.5 py-3">
<div className="flex items-center gap-0">
{Array.from({ length: TOTAL_STEPS }).map((_, i) => {
const step = i + 1;
const isCompleted = step < currentStep;
const isCurrent = step === currentStep;
return (
<div key={step} className="flex items-center">
{/* Connector line (before dot, except first) */}
{i > 0 && (
<div
className={`w-6 sm:w-8 h-0.5 transition-colors duration-300 ${
step <= currentStep ? "bg-active-primary" : "bg-gray-200"
}`}
/>
)}
{/* Step dot */}
<div
className={`flex items-center justify-center rounded-full transition-all duration-300 font-bold text-[0.65rem] ${
isCompleted
StepDivider function · typescript · L131-L141 (11 LOC)src/components/InstructionPhase.tsx
function StepDivider({ step, label }: { step: number; label: string }) {
return (
<div className="flex items-center gap-3 py-2" role="separator">
<div className="flex-1 h-px bg-active-primary/10" />
<span className="text-[0.7rem] font-bold uppercase tracking-wider text-active-primary/50 whitespace-nowrap">
Step {step}: {label}
</span>
<div className="flex-1 h-px bg-active-primary/10" />
</div>
);
}getPhaseIndex function · typescript · L18-L20 (3 LOC)src/components/PhaseIndicator.tsx
function getPhaseIndex(phase: Phase): number {
return phaseOrder.indexOf(phase);
}PhaseIndicator function · typescript · L22-L76 (55 LOC)src/components/PhaseIndicator.tsx
export default function PhaseIndicator({ currentPhase }: PhaseIndicatorProps) {
const currentIndex = getPhaseIndex(currentPhase);
return (
<div className="flex items-center gap-1.5 justify-center">
{phases.map((phase, index) => {
const phaseIdx = getPhaseIndex(phase.key);
const isActive = phaseIdx === currentIndex;
const isCompleted = phaseIdx < currentIndex;
return (
<Fragment key={phase.key}>
{index > 0 && (
<div
className={`w-5 h-0.5 rounded-full ${
isCompleted ? "bg-active-secondary" : "bg-[#e0dcd5]"
}`}
/>
)}
<div className="flex items-center gap-[5px]">
<span
className={`w-[22px] h-[22px] rounded-full flex items-center justify-center text-[0.6rem] font-extrabold text-white ${
isCompleted
? "bg-active-secondary"
: isActive
PhaseTransition function · typescript · L40-L118 (79 LOC)src/components/PhaseTransition.tsx
export default function PhaseTransition({ fromPhase, onContinue }: PhaseTransitionProps) {
const { coachName } = useTier();
const content = transitionContent[fromPhase];
const showButton = content.button !== null;
const quote = content.quote ?? `${coachName} is reading your work...`;
return (
<div className="h-[var(--content-height)] flex items-center justify-center bg-active-bg">
<div className="flex flex-col items-center text-center px-6 max-w-md">
{/* Big emoji with pop-in animation */}
<div className="animate-emoji-pop text-6xl sm:text-7xl mb-6" aria-hidden="true">
{content.emoji}
</div>
{/* Title */}
<h2 className="animate-fade-in text-2xl sm:text-3xl font-extrabold text-active-text mb-6">
{content.title}
</h2>
{/* Coach quote */}
<div className="animate-fade-in stagger-1 flex items-start gap-3 mb-8">
<CoachAvatar size="md" />
<p className="italic text-actAmbientParticles function · typescript · L11-L56 (46 LOC)src/components/placement/AmbientParticles.tsx
export function AmbientParticles({ trialIndex }: { trialIndex: number }) {
const colors = PALETTES[trialIndex] ?? PALETTES[0];
const particles = useMemo(() => {
return Array.from({ length: 15 }, (_, i) => {
const size = 4 + Math.random() * 8;
return {
key: `${trialIndex}-${i}`,
size,
color: colors[i % colors.length],
left: `${5 + Math.random() * 90}%`,
opacity: (0.15 + Math.random() * 0.15).toFixed(2),
rotation: `${Math.random() * 360}deg`,
duration: `${8 + Math.random() * 12}s`,
delay: `${Math.random() * 10}s`,
};
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [trialIndex]);
return (
<div
className="fixed inset-0 pointer-events-none overflow-hidden"
style={{ zIndex: 0 }}
aria-hidden="true"
>
{particles.map((p) => (
<div
key={p.key}
className="absolute rounded-full opacity-0"
style={{
wiPowered by Repobility — scan your code at https://repobility.com
QuestCard function · typescript · L22-L343 (322 LOC)src/components/placement/QuestCard.tsx
export function QuestCard({
trial,
trialIndex,
ollieSays,
prompt,
response,
onResponseChange,
onSubmit,
canSubmit,
wordCount,
saveStatus,
isTransitioning,
}: QuestCardProps) {
const inkPercent = Math.min(wordCount / 20, 1);
const isReady = inkPercent >= 1;
const isLastTrial = trialIndex === 2;
return (
<div
className="rounded-[28px] overflow-hidden bg-white"
style={{
boxShadow:
"0 8px 40px rgba(0,0,0,0.05), 0 1px 4px rgba(0,0,0,0.02)",
["--trial-accent" as string]: trial.accent,
["--trial-accent-light" as string]: trial.accentLight,
["--trial-accent-soft" as string]: trial.accentSoft,
["--trial-glow" as string]: trial.glow,
["--tag-color" as string]: trial.tagColor,
["--prompt-bg" as string]: trial.promptBg,
transition: "opacity 0.3s, transform 0.3s",
...(isTransitioning
? { opacity: 0, transform: "translateY(8px)" }
: { opacity: 1, transfQuestCharacter function · typescript · L956-L974 (19 LOC)src/components/placement/QuestCharacters.tsx
export function QuestCharacter({
id,
width = 52,
height = 52,
}: {
id: string;
width?: number;
height?: number;
}) {
return (
<svg
width={width}
height={height}
style={{ filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.1))" }}
>
<use href={`#char-${id}`} />
</svg>
);
}QuestFinale function · typescript · L11-L209 (199 LOC)src/components/placement/QuestFinale.tsx
export function QuestFinale({ visible, childName }: QuestFinaleProps) {
const stars = useMemo(() => {
return Array.from({ length: 30 }, (_, i) => ({
key: i,
left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`,
delay: `${Math.random() * 3}s`,
duration: `${1.5 + Math.random() * 1.5}s`,
size: 2 + Math.random() * 4,
}));
}, []);
const finalChars = [
{
config: TRIAL_CONFIG[0],
color: "#FF6B6B",
colorLight: "#FF8E8E",
},
{
config: TRIAL_CONFIG[1],
color: "#6C5CE7",
colorLight: "#A29BFE",
},
{
config: TRIAL_CONFIG[2],
color: "#E17055",
colorLight: "#FAB1A0",
},
];
return (
<div
className="fixed inset-0 flex flex-col items-center justify-center overflow-hidden"
style={{
zIndex: 200,
padding: 32,
opacity: visible ? 1 : 0,
pointerEvents: visible ? "auto" : "none",
transition: "opacity 0.8s",
buildNarrations function · typescript · L12-L38 (27 LOC)src/components/placement/QuestIntro.tsx
function buildNarrations(childName: string): NarrationStep[] {
return [
{
text: `Psst... ${childName}! Over here! I'm Ollie, keeper of the Story Realm — a world made entirely of words, where every tale ever told lives and breathes.`,
highlights: [
{ word: childName, type: "name" },
{ word: "Ollie", type: "highlight" },
],
btn: "Tell me more...",
},
{
text: `But the Story Realm is in trouble. Its pages are fading, and I need a new writer to join my quest and bring them back to life. I've been searching for someone with real writing magic...`,
highlights: [{ word: "fading", type: "highlight" }],
btn: "Could that be me?",
},
{
text: `I think it could! But first, every writer who joins the quest must pass three trials — to prove they have the spark of storytelling, the eye of a sense weaver, and the voice to move mountains.`,
highlights: [{ word: "three trials", type: "highlight" }],
btn: "WharenderNarrationText function · typescript · L40-L47 (8 LOC)src/components/placement/QuestIntro.tsx
function renderNarrationText(text: string, highlights: NarrationStep["highlights"]) {
let result = text;
for (const h of highlights) {
const cls = h.type === "name" ? "intro-name" : "intro-highlight";
result = result.replace(h.word, `<span class="${cls}">${h.word}</span>`);
}
return result;
}QuestIntro function · typescript · L54-L221 (168 LOC)src/components/placement/QuestIntro.tsx
export function QuestIntro({ childName, onComplete }: QuestIntroProps) {
const [narrationStep, setNarrationStep] = useState(0);
const [fading, setFading] = useState(false);
const narrations = buildNarrations(childName);
const current = narrations[narrationStep];
const handleNext = useCallback(() => {
setFading(true);
setTimeout(() => {
const next = narrationStep + 1;
if (next >= narrations.length) {
onComplete();
return;
}
setNarrationStep(next);
setFading(false);
}, 350);
}, [narrationStep, narrations.length, onComplete]);
return (
<div
className="fixed inset-0 flex flex-col items-center justify-center"
style={{
background: "linear-gradient(170deg, #FFF9F0 0%, #FFE8D6 40%, #FFF0E6 100%)",
padding: 24,
zIndex: 100,
transition: "opacity 0.8s, transform 0.6s",
}}
>
{/* Background glow */}
<div
style={{
position: "absolute",
QuestTrailSidebar function · typescript · L12-L182 (171 LOC)src/components/placement/QuestTrailSidebar.tsx
export function QuestTrailSidebar({
currentTrial,
completedTrials,
}: QuestTrailSidebarProps) {
// Determine how far the trail line has filled
// After completing trial N, fill to the offset for N
const lastCompleted = Math.max(...Array.from(completedTrials), -1);
const strokeDashoffset =
lastCompleted >= 0 ? TRAIL_OFFSETS[lastCompleted] : 220;
return (
<div className="w-[200px] shrink-0 pt-1">
<div
className="text-[11px] font-extrabold uppercase tracking-[2px] text-center mb-6"
style={{ color: "#2D343650" }}
>
Your Quest
</div>
<div className="relative pl-0">
{/* SVG connecting trail path */}
<svg
className="absolute pointer-events-none"
style={{ left: 44, top: 50, width: 6, overflow: "visible" }}
width="6"
height="240"
>
<line
x1="3"
y1="0"
x2="3"
y2="220"
stroke="#2D343618"
TrialCelebration function · typescript · L25-L145 (121 LOC)src/components/placement/TrialCelebration.tsx
export function TrialCelebration({
visible,
charId,
title,
subtitle,
onComplete,
}: TrialCelebrationProps) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (visible) {
timerRef.current = setTimeout(onComplete, 2500);
}
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [visible, onComplete]);
const confetti = useMemo(() => {
if (!visible) return [];
return Array.from({ length: 50 }, (_, i) => {
const s = Math.random() * 10 + 4;
const isRound = Math.random() > 0.4;
return {
key: i,
width: s,
height: s * (Math.random() * 0.5 + 0.5),
borderRadius: isRound ? "50%" : Math.random() > 0.5 ? "2px" : "0",
color: CONFETTI_COLORS[i % CONFETTI_COLORS.length],
left: `${25 + Math.random() * 50}%`,
top: `${20 + Math.random() * 15}%`,
fd: `${250 + Math.random() * 450}px`,
rot: `${(Math.randomRepobility · MCP-ready · https://repobility.com
CompletedCardWrapper function · typescript · L87-L125 (39 LOC)src/components/shared/AnswerCards.tsx
function CompletedCardWrapper({
headerEmoji,
headerLabel,
children,
}: {
headerEmoji: string;
headerLabel: string;
children: React.ReactNode;
}) {
return (
<div className="w-full rounded-2xl bg-[#e6f9f3] border-2 border-[#00b894] px-[18px] py-3.5 animate-fade-in">
<div className="text-[0.72rem] font-extrabold uppercase tracking-wider text-[#00b894] mb-2 flex items-center gap-1">
{headerEmoji} {headerLabel}
<span className="bg-[#00b894] text-white px-2 py-0.5 rounded-[10px] text-[0.72rem] font-bold ml-1.5 normal-case tracking-normal">
Completed
</span>
</div>
{children}
<div className="flex justify-end mt-1.5">
<span className="text-[#00b894] text-[0.85rem] font-bold flex items-center gap-1">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
ActiveCardWrapper function · typescript · L131-L148 (18 LOC)src/components/shared/AnswerCards.tsx
function ActiveCardWrapper({
headerEmoji,
headerLabel,
children,
}: {
headerEmoji: string;
headerLabel: string;
children: React.ReactNode;
}) {
return (
<div className="w-full rounded-2xl bg-gradient-to-br from-active-accent/10 to-active-accent/5 border-2 border-dashed border-active-accent px-[18px] py-3.5 animate-fade-in animate-pulse-border">
<div className="text-[0.82rem] font-extrabold uppercase tracking-wider text-[#c5a31d] mb-2 flex items-center gap-1">
{headerEmoji} {headerLabel}
</div>
{children}
</div>
);
}ChoiceCardCompleted function · typescript · L199-L243 (45 LOC)src/components/shared/AnswerCards.tsx
export function ChoiceCardCompleted({
options,
answer,
}: {
options: string[];
answer: string;
}) {
return (
<CompletedCardWrapper headerEmoji="👆" headerLabel="Your answer">
<div className="flex flex-col gap-1.5">
{options.map((option) => {
const isSelected = option === answer;
return (
<div
key={option}
className={`rounded-xl border-2 px-4 py-2.5 text-[0.95rem] flex items-center gap-2 transition-colors
${
isSelected
? "bg-[#00b894]/10 border-[#00b894] text-active-text font-bold"
: "bg-white/50 border-gray-200 text-active-text/40"
}`}
>
{isSelected && (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="#00b894"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="ro