diff --git a/ROADMAP.md b/ROADMAP.md
index ecdc440..e19255c 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -43,20 +43,20 @@ Core engine, React component, and public website shipped.
- [x] More supported tools: Windsurf, Replit, Linear, Figma AI, v0 (13 total)
- [x] More roles: Sales, Customer Success, Finance (11 total)
- [x] `generateLessonContent(lesson)` — returns full lesson body, exercises, and rubric
+- [x] Lesson prerequisite graph — sequential `prerequisiteIds[]` on each lesson; ProgressTracker locks accordingly
- [ ] `progress` field in `LearningPath` to track completed lessons
-- [ ] Lesson prerequisite graph — reorder-aware sequencing
### React package
-- [x] ` ` component — persists lesson completion state to localStorage
-- [ ] ` ` — renders full lesson body returned by `generateLessonContent()`
-- [ ] `light` theme variant (in addition to `warm`, `midnight`, `technical`)
-- [ ] Headless mode: all components accept `renderItem` render-prop overrides
+- [x] ` ` component — persists lesson completion state to localStorage; prerequisite-aware lesson locking
+- [x] ` ` — renders full lesson body, exercises, and rubric from `generateLessonContent()`
+- [x] `light` theme variant (in addition to `warm`, `midnight`, `technical`)
+- [x] Headless mode: `renderItem` render-prop on `LearningPath` and `ProgressTracker`
### Web
- [x] `/changelog` page — versioned release notes
- [x] `/compare/[slug]` pages — Claude vs ChatGPT, Cursor vs Copilot, Windsurf vs Cursor, and more
- [x] `/guides/[slug]` — Next.js integration, theming, CLI, generateLessonContent guides
-- [ ] Per-role `opengraph-image` for `/roles/[slug]` pages
+- [x] `opengraph-image` for `/roles/[slug]` and `/tools/[slug]` pages
### Developer experience
- [x] `@learnkit-ai/cli` — `npx @learnkit-ai/cli generate` outputs a JSON learning path
diff --git a/apps/web/src/app/roles/[slug]/opengraph-image.tsx b/apps/web/src/app/roles/[slug]/opengraph-image.tsx
new file mode 100644
index 0000000..e40e1cc
--- /dev/null
+++ b/apps/web/src/app/roles/[slug]/opengraph-image.tsx
@@ -0,0 +1,121 @@
+import { ImageResponse } from 'next/og';
+import { ROLES } from '@/lib/seo-data';
+
+export const runtime = 'edge';
+export const size = { width: 1200, height: 630 };
+export const contentType = 'image/png';
+
+export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
+ const { slug } = await params;
+ const role = ROLES.find((r) => r.slug === slug);
+ if (!role) return new Response('Not found', { status: 404 });
+
+ return new ImageResponse(
+ (
+
+ {/* Top: brand */}
+
+ LearnKit AI
+
+
+ {/* Middle: role name + tagline */}
+
+
+ 30-day AI learning path
+
+
+ AI training for
+
+ {role.name}s .
+
+
+ {role.blurb}
+
+
+
+ {/* Bottom: stats + tools */}
+
+
+ 4 weeks
+ 12 lessons
+ Apache-2.0
+
+
+ learnkit-ai.com
+
+
+
+ ),
+ { ...size },
+ );
+}
diff --git a/apps/web/src/app/tools/[slug]/opengraph-image.tsx b/apps/web/src/app/tools/[slug]/opengraph-image.tsx
new file mode 100644
index 0000000..4536f3e
--- /dev/null
+++ b/apps/web/src/app/tools/[slug]/opengraph-image.tsx
@@ -0,0 +1,144 @@
+import { ImageResponse } from 'next/og';
+import { TOOLS } from '@/lib/seo-data';
+
+export const runtime = 'edge';
+export const size = { width: 1200, height: 630 };
+export const contentType = 'image/png';
+
+export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
+ const { slug } = await params;
+ const tool = TOOLS.find((t) => t.slug === slug);
+ if (!tool) return new Response('Not found', { status: 404 });
+
+ return new ImageResponse(
+ (
+
+ {/* Top: brand */}
+
+ LearnKit AI
+
+
+ {/* Middle: tool name + tagline */}
+
+
+
+ {tool.name[0]}
+
+
+ {tool.vendor}
+
+
+
+ {tool.tagline}.
+
+
+ {tool.blurb}
+
+
+
+ {/* Bottom: stats */}
+
+
+ {tool.modules} modules
+ ~{tool.hours}h
+ Apache-2.0
+
+
+ learnkit-ai.com
+
+
+
+ ),
+ { ...size },
+ );
+}
diff --git a/apps/web/src/components/demo/DemoFlow.tsx b/apps/web/src/components/demo/DemoFlow.tsx
index eb3ec52..9a8c96a 100644
--- a/apps/web/src/components/demo/DemoFlow.tsx
+++ b/apps/web/src/components/demo/DemoFlow.tsx
@@ -19,6 +19,9 @@ const ROLES = [
'Founder',
'Operations',
'Researcher',
+ 'Sales',
+ 'Customer Success',
+ 'Finance',
];
const ALL_TOOLS = [
@@ -30,6 +33,11 @@ const ALL_TOOLS = [
'Notion AI',
'Perplexity',
'Gemini',
+ 'Windsurf',
+ 'Replit',
+ 'Linear',
+ 'Figma AI',
+ 'v0',
];
const GOAL_SAMPLES = [
@@ -558,7 +566,7 @@ export function DemoFlow() {
>
}
>
- setStep(4)} />
+ setStep(4)} />
)}
diff --git a/apps/web/src/components/demo/LessonPreview.tsx b/apps/web/src/components/demo/LessonPreview.tsx
index 2643e7b..201488e 100644
--- a/apps/web/src/components/demo/LessonPreview.tsx
+++ b/apps/web/src/components/demo/LessonPreview.tsx
@@ -1,37 +1,34 @@
'use client';
-import { useState } from 'react';
+import { generateLearningPath, generateLessonContent } from '@learnkit-ai/core';
import { Button, ArrowR } from '@/components/ui/Button';
import { Ole } from '@/components/ui/primitives';
import { FlowFooter } from './StepShell';
-const OUTLINE = [
- 'What a system prompt does',
- 'Anatomy of a great prompt',
- 'Try one: rewriting your standup',
- 'Common mistakes',
- 'Practice & ship',
-];
-
export function LessonPreview({
role,
tools,
+ goal,
+ level,
onBack,
}: {
role: string;
tools: string[];
+ goal?: string;
+ level?: 'beginner' | 'intermediate' | 'advanced';
onBack: () => void;
}) {
- const [section, setSection] = useState(1);
- const primaryTool = tools[0] ?? 'Claude';
- const roleShort = (role || 'Product Manager').split(' ').slice(-1)[0].toLowerCase();
+ const path = generateLearningPath({
+ role: role || 'Product Manager',
+ tools: tools.length > 0 ? tools : ['Claude'],
+ goal: goal || 'Ship an AI feature this sprint',
+ level: level ?? 'beginner',
+ });
- const exampleForTool = () => {
- if (primaryTool === 'Cursor') return '"You are a senior engineer. Refactor this file."';
- if (primaryTool === 'Claude') return `"You are a senior ${roleShort}. Review this spec."`;
- if (primaryTool === 'ChatGPT') return '"You are a research assistant. Summarize this report."';
- return `"You are a ${roleShort}'s assistant. Help with this task."`;
- };
+ const firstLesson = path.weeks[0]!.lessons[0]!;
+ const content = generateLessonContent(firstLesson);
+ const [firstPara, secondPara] = content.body.split('\n\n');
+ const firstExercise = content.exercises[0]!;
return (
- {/* Outline */}
+ {/* Outline: week 1 lessons */}
- Outline
+ Week 1 — {path.weeks[0]!.title}
- {OUTLINE.map((t, i) => (
+ {path.weeks[0]!.lessons.map((l, i) => (
setSection(i)}
+ key={l.id}
style={{
display: 'flex',
gap: 10,
padding: '8px 8px',
borderRadius: 6,
marginBottom: 2,
- background: section === i ? 'var(--paper-3)' : 'transparent',
+ background: i === 0 ? 'var(--paper-3)' : 'transparent',
cursor: 'pointer',
fontSize: 12.5,
- fontWeight: section === i ? 500 : 400,
+ fontWeight: i === 0 ? 500 : 400,
color: 'var(--ink)',
}}
>
@@ -96,16 +92,17 @@ export function LessonPreview({
fontFamily: 'var(--mono)',
fontSize: 11,
width: 16,
+ flexShrink: 0,
}}
>
{i + 1}
- {t}
+ {l.title}
))}
- {/* Content */}
+ {/* Content: real lesson body */}
- Section 02 · Anatomy
+ Day {firstLesson.day} · {firstLesson.kind} · {firstLesson.minutes}m
- A great system prompt has{' '}
- three things.
+ {firstLesson.title}
- Most people open ChatGPT and start asking. The pros write a{' '}
- persona , define a{' '}
- process , and set{' '}
- boundaries . That's it. Three
- knobs.
+ {firstPara}
+ {secondPara && (
+
+ {secondPara}
+
+ )}
-
# persona
-
- You are a
skeptical senior PM
+
+ Exercise 1
-
# process
-
For each user input, you:
-
1. Ask one clarifying question
-
2. List 3 alternatives
-
3. Pick one with rationale
-
# boundaries
-
Never invent metrics. If unsure, say so.
+
+ {firstExercise.prompt}
+
-
- Try it now in the workbench on the right →
-
- {/* AI Guide chat */}
+ {/* AI Guide */}
- I noticed you picked{' '}
- {primaryTool} as your main tool —
- I swapped the example to fit. Spot the missing knob:
+ This is your first lesson as a{' '}
+ {role} . Your primary tool is{' '}
+ {firstLesson.tool} . The exercise
+ below is a real task — not a tutorial. Do it before reading ahead.
- {exampleForTool()}
-
-
- {['Persona', 'Process', 'Boundaries'].map((o, i) => (
-
-
- {String.fromCharCode(65 + i)}
-
- {o}
-
- ))}
+ Reviewer note: {' '}
+ {firstExercise.rubricHint}
- Continue to section 03
+ Continue to exercise 2
diff --git a/packages/core/src/__tests__/generate.test.ts b/packages/core/src/__tests__/generate.test.ts
index c9aef2f..549b15e 100644
--- a/packages/core/src/__tests__/generate.test.ts
+++ b/packages/core/src/__tests__/generate.test.ts
@@ -71,6 +71,31 @@ describe('generateLearningPath', () => {
}
});
+ it('first lesson has no prerequisites', () => {
+ const path = generateLearningPath(SAMPLE);
+ expect(path.weeks[0]!.lessons[0]!.prerequisiteIds).toHaveLength(0);
+ });
+
+ it('subsequent lessons have exactly one prerequisite', () => {
+ const path = generateLearningPath(SAMPLE);
+ const allLessons = path.weeks.flatMap((w) => w.lessons);
+ for (let i = 1; i < allLessons.length; i++) {
+ expect(allLessons[i]!.prerequisiteIds).toHaveLength(1);
+ }
+ });
+
+ it('prerequisite ids reference real lesson ids', () => {
+ const path = generateLearningPath(SAMPLE);
+ const allIds = new Set(path.weeks.flatMap((w) => w.lessons.map((l) => l.id)));
+ for (const w of path.weeks) {
+ for (const l of w.lessons) {
+ for (const pid of l.prerequisiteIds) {
+ expect(allIds.has(pid)).toBe(true);
+ }
+ }
+ }
+ });
+
it.each([
'Marketer',
'Founder',
diff --git a/packages/core/src/generate.ts b/packages/core/src/generate.ts
index 140df77..9211489 100644
--- a/packages/core/src/generate.ts
+++ b/packages/core/src/generate.ts
@@ -1073,12 +1073,25 @@ export function generateLearningPath(rawInput: LearningPathInput): LearningPath
const parsedCtx = input.companyContext ? parseCompanyContext(input.companyContext) : null;
const ctxSuffix = parsedCtx ? buildContextSuffix(parsedCtx) : '';
- const weeks = (ROLE_WEEKS[role] ?? GENERIC_WEEKS).map((w, wi) => {
+ const weekDefs = ROLE_WEEKS[role] ?? GENERIC_WEEKS;
+
+ const weeks = weekDefs.map((w, wi) => {
const tool = wi % 2 === 0 ? primaryTool : secondaryTool;
const lessons: Lesson[] = w.templates.map((template, li) => {
const dayBase = wi * 7 + li * 2 + 1;
const minutesAdjusted = Math.max(8, template.minutes + LEVEL_BIAS[input.level]);
const baseSummary = template.summary({ tool, role, goal: input.goal });
+
+ let prerequisiteIds: string[] = [];
+ if (wi === 0 && li === 0) {
+ prerequisiteIds = [];
+ } else if (li === 0) {
+ const prevTemplates = weekDefs[wi - 1]!.templates;
+ prerequisiteIds = [hashId('l', stableSeed, wi - 1, prevTemplates.length - 1)];
+ } else {
+ prerequisiteIds = [hashId('l', stableSeed, wi, li - 1)];
+ }
+
return {
id: hashId('l', stableSeed, wi, li),
day: dayBase,
@@ -1087,6 +1100,7 @@ export function generateLearningPath(rawInput: LearningPathInput): LearningPath
tool,
minutes: minutesAdjusted,
kind: template.kind,
+ prerequisiteIds,
};
});
return { index: (wi + 1) as 1 | 2 | 3 | 4, title: w.title, lessons } satisfies Week;
diff --git a/packages/react/src/LearningPath.tsx b/packages/react/src/LearningPath.tsx
index a051bdb..050bf2a 100644
--- a/packages/react/src/LearningPath.tsx
+++ b/packages/react/src/LearningPath.tsx
@@ -1,15 +1,17 @@
'use client';
-import type { CSSProperties } from 'react';
+import type { CSSProperties, ReactNode } from 'react';
import type { LearningPathInput, Lesson } from '@learnkit-ai/schemas';
import { LessonCard } from './LessonCard';
+import type { LessonStatus } from './LessonCard';
import { useLearnKit } from './useLearnKit';
-export type LearnKitTheme = 'warm' | 'midnight' | 'technical';
+export type LearnKitTheme = 'warm' | 'midnight' | 'technical' | 'light';
export interface LearningPathProps {
input: LearningPathInput;
onLessonClick?: (lesson: Lesson) => void;
+ renderItem?: (lesson: Lesson, status: LessonStatus) => ReactNode;
theme?: LearnKitTheme;
className?: string;
style?: CSSProperties;
@@ -49,11 +51,23 @@ export const THEMES: Record = {
['--lk-rule' as string]: 'rgba(230, 230, 230, 0.08)',
['--lk-rule-strong' as string]: 'rgba(230, 230, 230, 0.16)',
},
+ light: {
+ ['--lk-surface' as string]: '#FFFFFF',
+ ['--lk-ink' as string]: '#111827',
+ ['--lk-ink-soft' as string]: '#374151',
+ ['--lk-muted' as string]: '#9CA3AF',
+ ['--lk-accent' as string]: '#6366F1',
+ ['--lk-accent-2' as string]: '#EC4899',
+ ['--lk-accent-3' as string]: '#10B981',
+ ['--lk-rule' as string]: 'rgba(17, 24, 39, 0.08)',
+ ['--lk-rule-strong' as string]: 'rgba(17, 24, 39, 0.16)',
+ },
};
export function LearningPath({
input,
onLessonClick,
+ renderItem,
theme = 'warm',
className,
style,
@@ -143,20 +157,24 @@ export function LearningPath({
{week.title}
- {week.lessons.map((lesson, li) => (
-
- ))}
+ {week.lessons.map((lesson, li) => {
+ const status: LessonStatus =
+ week.index === 1 && li === 0
+ ? 'in-progress'
+ : week.index === 4
+ ? 'locked'
+ : 'available';
+ return renderItem ? (
+
{renderItem(lesson, status)}
+ ) : (
+
+ );
+ })}
))}
diff --git a/packages/react/src/LessonDetail.tsx b/packages/react/src/LessonDetail.tsx
new file mode 100644
index 0000000..eacd14b
--- /dev/null
+++ b/packages/react/src/LessonDetail.tsx
@@ -0,0 +1,251 @@
+'use client';
+
+import type { CSSProperties } from 'react';
+import { generateLessonContent } from '@learnkit-ai/core';
+import type { Lesson } from '@learnkit-ai/schemas';
+import type { LearnKitTheme } from './LearningPath';
+import { THEMES } from './LearningPath';
+
+export interface LessonDetailProps {
+ lesson: Lesson;
+ theme?: LearnKitTheme;
+ className?: string;
+ style?: CSSProperties;
+}
+
+const KIND_COLORS: Record = {
+ lesson: 'var(--lk-accent-2, #E8B547)',
+ project: 'var(--lk-accent, #C8472A)',
+ practicum: 'var(--lk-accent-3, #6B8F6E)',
+};
+
+export function LessonDetail({ lesson, theme = 'warm', className, style }: LessonDetailProps) {
+ const content = generateLessonContent(lesson);
+ const kindColor = KIND_COLORS[lesson.kind] ?? 'var(--lk-muted)';
+
+ return (
+
+ {/* Meta row */}
+
+
+ {lesson.kind}
+
+ {lesson.tool}
+ ·
+ Day {lesson.day}
+ ·
+ {lesson.minutes}m
+
+
+ {/* Title */}
+
+ {lesson.title}
+
+
+ {/* Body */}
+
+ {content.body.split('\n\n').map((para, i) => (
+
+ {para}
+
+ ))}
+
+
+ {/* Exercises */}
+
+
+ Exercises
+
+
+ {content.exercises.map((ex, i) => (
+
+
+ Exercise {i + 1}
+
+
+ {ex.prompt}
+
+
+
+ Expected output:
+ {ex.expectedOutput}
+
+
+ Reviewer note:
+ {ex.rubricHint}
+
+
+
+ ))}
+
+
+
+ {/* Rubric */}
+
+
+ Rubric
+
+
+ {content.rubric.map((item, i) => (
+
+
+ {item.criterion}
+
+ {(
+ [
+ { label: 'Excellent', text: item.excellent, color: 'var(--lk-accent-3, #6B8F6E)' },
+ { label: 'Acceptable', text: item.acceptable, color: 'var(--lk-accent-2, #E8B547)' },
+ { label: 'Needs work', text: item.needsWork, color: 'var(--lk-accent, #C8472A)' },
+ ] as const
+ ).map(({ label, text, color }) => (
+
+
+ {label}
+
+
+ {text}
+
+
+ ))}
+
+ ))}
+
+
+
+ );
+}
diff --git a/packages/react/src/ProgressTracker.tsx b/packages/react/src/ProgressTracker.tsx
index fbcb891..2f54e30 100644
--- a/packages/react/src/ProgressTracker.tsx
+++ b/packages/react/src/ProgressTracker.tsx
@@ -1,7 +1,7 @@
'use client';
import { useState, useCallback } from 'react';
-import type { CSSProperties } from 'react';
+import type { CSSProperties, ReactNode } from 'react';
import { generateLearningPath } from '@learnkit-ai/core';
import type { LearningPathInput, Lesson } from '@learnkit-ai/schemas';
import { LessonCard } from './LessonCard';
@@ -13,6 +13,7 @@ export interface ProgressTrackerProps {
input: LearningPathInput;
theme?: LearnKitTheme;
onLessonClick?: (lesson: Lesson) => void;
+ renderItem?: (lesson: Lesson, status: LessonStatus) => ReactNode;
className?: string;
style?: CSSProperties;
}
@@ -36,16 +37,32 @@ function writeStorage(key: string, ids: Set): void {
}
}
-function deriveStatus(lessonId: string, allIds: string[], completedIds: Set): LessonStatus {
- if (completedIds.has(lessonId)) return 'completed';
- const firstIncomplete = allIds.find((id) => !completedIds.has(id));
- return lessonId === firstIncomplete ? 'in-progress' : 'available';
+function isEffectiveDone(lessonId: string, allLessons: Lesson[], completedIds: Set): boolean {
+ if (!completedIds.has(lessonId)) return false;
+ const lesson = allLessons.find((l) => l.id === lessonId);
+ if (!lesson) return false;
+ return lesson.prerequisiteIds.every((pid) => isEffectiveDone(pid, allLessons, completedIds));
+}
+
+function deriveStatus(lesson: Lesson, allLessons: Lesson[], completedIds: Set): LessonStatus {
+ if (isEffectiveDone(lesson.id, allLessons, completedIds)) return 'completed';
+ const prereqsMet = lesson.prerequisiteIds.every((pid) =>
+ isEffectiveDone(pid, allLessons, completedIds),
+ );
+ if (!prereqsMet) return 'locked';
+ const firstAvailable = allLessons.find(
+ (l) =>
+ !isEffectiveDone(l.id, allLessons, completedIds) &&
+ l.prerequisiteIds.every((pid) => isEffectiveDone(pid, allLessons, completedIds)),
+ );
+ return lesson.id === firstAvailable?.id ? 'in-progress' : 'available';
}
export function ProgressTracker({
input,
theme = 'warm',
onLessonClick,
+ renderItem,
className,
style,
}: ProgressTrackerProps) {
@@ -101,8 +118,11 @@ export function ProgressTracker({
);
}
- const allIds = path.weeks.flatMap((w) => w.lessons.map((l) => l.id));
- const completedCount = allIds.filter((id) => completedIds.has(id)).length;
+ const allLessons = path.weeks.flatMap((w) => w.lessons);
+ const allIds = allLessons.map((l) => l.id);
+ const completedCount = allIds.filter((id) =>
+ isEffectiveDone(id, allLessons, completedIds),
+ ).length;
return (
- {week.lessons.map((lesson) => (
-
- ))}
+ {week.lessons.map((lesson) => {
+ const status = deriveStatus(lesson, allLessons, completedIds);
+ return renderItem ? (
+
{renderItem(lesson, status)}
+ ) : (
+
+ );
+ })}
))}
diff --git a/packages/react/src/__tests__/LearningPath.test.tsx b/packages/react/src/__tests__/LearningPath.test.tsx
index 6eaca1b..4f1c8dd 100644
--- a/packages/react/src/__tests__/LearningPath.test.tsx
+++ b/packages/react/src/__tests__/LearningPath.test.tsx
@@ -30,14 +30,29 @@ describe('LearningPath', () => {
expect(screen.getByText(/Invalid LearningPathInput/)).toBeInTheDocument();
});
- it('renders without throwing for all three themes', () => {
- const themes = ['warm', 'midnight', 'technical'] as const;
+ it('renders without throwing for all four themes', () => {
+ const themes = ['warm', 'midnight', 'technical', 'light'] as const;
for (const theme of themes) {
const { unmount } = render(
);
unmount();
}
});
+ it('calls renderItem instead of the default LessonCard when provided', () => {
+ const rendered: string[] = [];
+ render(
+
{
+ rendered.push(lesson.id);
+ return {lesson.title}
;
+ }}
+ />,
+ );
+ expect(rendered.length).toBe(12);
+ expect(screen.queryAllByRole('button')).toHaveLength(0);
+ });
+
it('shows lesson count summary line', () => {
render( );
expect(screen.getByText(/4 weeks/)).toBeInTheDocument();
diff --git a/packages/react/src/__tests__/LessonCard.test.tsx b/packages/react/src/__tests__/LessonCard.test.tsx
index a415b35..1fe60dd 100644
--- a/packages/react/src/__tests__/LessonCard.test.tsx
+++ b/packages/react/src/__tests__/LessonCard.test.tsx
@@ -11,6 +11,7 @@ const LESSON: Lesson = {
tool: 'Claude',
minutes: 12,
kind: 'lesson',
+ prerequisiteIds: [],
};
describe('LessonCard', () => {
diff --git a/packages/react/src/__tests__/LessonDetail.test.tsx b/packages/react/src/__tests__/LessonDetail.test.tsx
new file mode 100644
index 0000000..ee67d4d
--- /dev/null
+++ b/packages/react/src/__tests__/LessonDetail.test.tsx
@@ -0,0 +1,70 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { generateLearningPath } from '@learnkit-ai/core';
+import type { LearningPathInput } from '@learnkit-ai/schemas';
+import { LessonDetail } from '../LessonDetail';
+
+const INPUT: LearningPathInput = {
+ role: 'Software Engineer',
+ tools: ['Claude'],
+ goal: 'Build an AI code review tool',
+ level: 'intermediate',
+};
+
+function getFirstLesson() {
+ return generateLearningPath(INPUT).weeks[0]!.lessons[0]!;
+}
+
+describe('LessonDetail', () => {
+ it('renders the lesson title', () => {
+ const lesson = getFirstLesson();
+ render( );
+ expect(screen.getByText(lesson.title)).toBeInTheDocument();
+ });
+
+ it('renders the lesson kind badge', () => {
+ const lesson = getFirstLesson();
+ render( );
+ expect(screen.getByText(lesson.kind)).toBeInTheDocument();
+ });
+
+ it('renders exercises section', () => {
+ const lesson = getFirstLesson();
+ render( );
+ expect(screen.getByText(/Exercises/i)).toBeInTheDocument();
+ expect(screen.getByText(/Exercise 1/i)).toBeInTheDocument();
+ });
+
+ it('renders rubric section', () => {
+ const lesson = getFirstLesson();
+ render( );
+ expect(screen.getByText(/^Rubric$/i)).toBeInTheDocument();
+ expect(screen.getAllByText(/^Excellent$/i).length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText(/^Needs work$/i).length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('renders without throwing for a project lesson', () => {
+ const path = generateLearningPath(INPUT);
+ const project = path.weeks.flatMap((w) => w.lessons).find((l) => l.kind === 'project');
+ expect(project).toBeDefined();
+ const { container } = render( );
+ expect(container).toBeTruthy();
+ });
+
+ it('renders without throwing for a practicum lesson', () => {
+ const path = generateLearningPath(INPUT);
+ const practicum = path.weeks.flatMap((w) => w.lessons).find((l) => l.kind === 'practicum');
+ expect(practicum).toBeDefined();
+ const { container } = render( );
+ expect(container).toBeTruthy();
+ });
+
+ it('renders without throwing for all four themes', () => {
+ const lesson = getFirstLesson();
+ const themes = ['warm', 'midnight', 'technical', 'light'] as const;
+ for (const theme of themes) {
+ const { unmount } = render( );
+ unmount();
+ }
+ });
+});
diff --git a/packages/react/src/__tests__/ProgressTracker.test.tsx b/packages/react/src/__tests__/ProgressTracker.test.tsx
index 47b9e06..4c19b3f 100644
--- a/packages/react/src/__tests__/ProgressTracker.test.tsx
+++ b/packages/react/src/__tests__/ProgressTracker.test.tsx
@@ -57,11 +57,41 @@ describe('ProgressTracker', () => {
expect(screen.getByText(/role is required|Invalid|Unable/i)).toBeInTheDocument();
});
- it('renders without throwing for all three themes', () => {
- const themes = ['warm', 'midnight', 'technical'] as const;
+ it('renders without throwing for all four themes', () => {
+ const themes = ['warm', 'midnight', 'technical', 'light'] as const;
for (const theme of themes) {
const { unmount } = render( );
unmount();
}
});
+
+ it('second lesson is locked until the first is complete', () => {
+ render( );
+ const buttons = screen.getAllByRole('button');
+ // Button at index 1 should be disabled (locked — prerequisites not met)
+ expect(buttons[1]).toBeDisabled();
+ });
+
+ it('second lesson unlocks after first is completed', () => {
+ render( );
+ const buttons = screen.getAllByRole('button');
+ fireEvent.click(buttons[0]!);
+ // Now buttons[1] should be enabled (in-progress)
+ expect(screen.getAllByRole('button')[1]).not.toBeDisabled();
+ });
+
+ it('calls renderItem instead of default LessonCard when provided', () => {
+ const rendered: string[] = [];
+ render(
+ {
+ rendered.push(lesson.id);
+ return {lesson.title}
;
+ }}
+ />,
+ );
+ expect(rendered.length).toBe(12);
+ expect(screen.queryAllByRole('button')).toHaveLength(0);
+ });
});
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index 44e9997..498b10d 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -10,3 +10,4 @@ export { LessonCard, type LessonCardProps, type LessonStatus } from './LessonCar
export { AIGuide, type AIGuideProps } from './AIGuide';
export { useLearnKit, type UseLearnKitResult } from './useLearnKit';
export { ProgressTracker, type ProgressTrackerProps } from './ProgressTracker';
+export { LessonDetail, type LessonDetailProps } from './LessonDetail';
diff --git a/packages/schemas/src/__tests__/index.test.ts b/packages/schemas/src/__tests__/index.test.ts
index c57c9c7..bd34dc9 100644
--- a/packages/schemas/src/__tests__/index.test.ts
+++ b/packages/schemas/src/__tests__/index.test.ts
@@ -59,6 +59,7 @@ describe('LessonSchema', () => {
tool: 'Claude',
minutes: 12,
kind: 'lesson' as const,
+ prerequisiteIds: [],
};
expect(LessonSchema.safeParse({ ...base, day: 0 }).success).toBe(false);
expect(LessonSchema.safeParse({ ...base, day: 31 }).success).toBe(false);
@@ -94,6 +95,7 @@ describe('LearningPathSchema', () => {
tool: 'Claude',
minutes: 10,
kind: 'lesson' as const,
+ prerequisiteIds: [],
},
],
});
diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts
index 42d416c..f9eeeec 100644
--- a/packages/schemas/src/index.ts
+++ b/packages/schemas/src/index.ts
@@ -28,6 +28,7 @@ export const LessonSchema = z.object({
tool: z.string(),
minutes: z.number().int().min(1),
kind: LessonKindSchema,
+ prerequisiteIds: z.array(z.string()),
});
export type Lesson = z.infer;