← back to karthikjanagiraman__WritingCoach

Function bodies 269 total

All specs Real LLM only Function bodies
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);
  cons
init 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 Error
countWords 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, setC
handleResponseChange 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 max
ActivityHeatmap 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; cou
Repobility · 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-prim
stripLeadingMascot 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-e
LockedCard 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">&#x1F512;</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>&#x270F;&#xFE0F;</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-act
AmbientParticles 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={{
            wi
Powered 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, transf
QuestCharacter 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: "Wha
renderNarrationText 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.random
Repobility · 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
‹ prevpage 3 / 6next ›