From afa7d920d47e01679b82a8e930d310a3bffca18f Mon Sep 17 00:00:00 2001 From: raj-khan Date: Sun, 24 May 2026 19:42:49 +0800 Subject: [PATCH] Add v1 depth: tools, roles, generateLessonContent, ProgressTracker, /compare, /guides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tools (8→13): Windsurf, Replit, Linear, Figma AI, v0 Roles (8→11): Sales, Customer Success, Finance — each with 3 custom weeks + Practicum generateLessonContent(): pure, deterministic body + exercises + rubric per lesson : localStorage-backed lesson completion, toggleable per lesson /compare/[slug]: 5 tool comparison pages (Claude vs ChatGPT, Cursor vs Copilot, etc.) /guides/[slug]: 4 integration guides (Next.js embed, theming, CLI, lesson content) Sitemap updated; schemas extended with LessonContent + LearningPathProgress types 30 core tests, 28 react tests — all passing --- ROADMAP.md | 20 +- apps/web/src/app/compare/[slug]/page.tsx | 369 ++++++++++++++++++ apps/web/src/app/guides/[slug]/page.tsx | 252 ++++++++++++ apps/web/src/app/sitemap.ts | 18 +- apps/web/src/components/ui/primitives.tsx | 5 + apps/web/src/lib/compare-data.ts | 242 ++++++++++++ apps/web/src/lib/guides-data.ts | 350 +++++++++++++++++ apps/web/src/lib/seo-data.ts | 102 +++++ packages/core/src/__tests__/generate.test.ts | 5 +- .../core/src/__tests__/lesson-content.test.ts | 92 +++++ packages/core/src/generate.ts | 246 ++++++++++++ packages/core/src/index.ts | 9 + packages/core/src/lesson-content.ts | 148 +++++++ packages/core/src/roles.ts | 17 + packages/core/src/tools.ts | 13 + packages/react/src/LearningPath.tsx | 2 +- packages/react/src/ProgressTracker.tsx | 175 +++++++++ .../src/__tests__/ProgressTracker.test.tsx | 67 ++++ packages/react/src/index.ts | 3 +- packages/schemas/src/index.ts | 30 ++ 20 files changed, 2151 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/app/compare/[slug]/page.tsx create mode 100644 apps/web/src/app/guides/[slug]/page.tsx create mode 100644 apps/web/src/lib/compare-data.ts create mode 100644 apps/web/src/lib/guides-data.ts create mode 100644 packages/core/src/__tests__/lesson-content.test.ts create mode 100644 packages/core/src/lesson-content.ts create mode 100644 packages/react/src/ProgressTracker.tsx create mode 100644 packages/react/src/__tests__/ProgressTracker.test.tsx diff --git a/ROADMAP.md b/ROADMAP.md index 9c093b5..ecdc440 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -36,30 +36,30 @@ Core engine, React component, and public website shipped. --- -## v1 — Depth (planned) +## v1 — Depth (in progress) ### Engine -- [ ] `companyContext` field used in lesson personalisation (currently accepted but not applied) -- [ ] More supported tools (target: 20+), including Windsurf, Replit, Linear, Figma AI, v0 -- [ ] More roles: Sales, Customer Success, Legal, Finance, Executive +- [x] `companyContext` field personalises project lesson summaries (stack, pace, team-size hints) +- [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 - [ ] `progress` field in `LearningPath` to track completed lessons - [ ] Lesson prerequisite graph — reorder-aware sequencing -- [ ] `generateLessonContent(lesson)` — returns full lesson body, exercises, and rubric ### React package -- [ ] `` component — persist lesson state to localStorage +- [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 ### Web -- [ ] `/changelog` page — versioned release notes -- [ ] `/compare` pages — e.g. "Claude vs ChatGPT for engineers" -- [ ] `/guides/[slug]` — long-form integration guides +- [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 ### Developer experience -- [ ] `@learnkit-ai/cli` — `npx @learnkit-ai/cli generate` outputs a JSON learning path +- [x] `@learnkit-ai/cli` — `npx @learnkit-ai/cli generate` outputs a JSON learning path - [ ] VS Code extension — sidebar learning path panel - [ ] Storybook for `@learnkit-ai/react` components diff --git a/apps/web/src/app/compare/[slug]/page.tsx b/apps/web/src/app/compare/[slug]/page.tsx new file mode 100644 index 0000000..7217a29 --- /dev/null +++ b/apps/web/src/app/compare/[slug]/page.tsx @@ -0,0 +1,369 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import Link from 'next/link'; +import { Nav } from '@/components/layout/Nav'; +import { Footer } from '@/components/layout/Footer'; +import { Button, ArrowR } from '@/components/ui/Button'; +import { Eyebrow, AmbientArc, ToolIcon } from '@/components/ui/primitives'; +import { COMPARISONS, SITE_URL } from '@/lib/compare-data'; + +export function generateStaticParams() { + return COMPARISONS.map((c) => ({ slug: c.slug })); +} + +export async function generateMetadata( + { params }: { params: Promise<{ slug: string }> }, +): Promise { + const { slug } = await params; + const page = COMPARISONS.find((c) => c.slug === slug); + if (!page) return {}; + + const title = page.tagline; + const description = page.intro.slice(0, 155); + + return { + title, + description, + keywords: page.keywords, + alternates: { canonical: `/compare/${page.slug}` }, + openGraph: { + type: 'article', + url: `${SITE_URL}/compare/${page.slug}`, + title, + description, + siteName: 'LearnKit AI', + }, + twitter: { card: 'summary_large_image', title, description }, + }; +} + +export default async function ComparePage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const page = COMPARISONS.find((c) => c.slug === slug); + if (!page) notFound(); + + const related = COMPARISONS.filter((c) => c.slug !== page.slug).slice(0, 3).map((c) => ({ + href: `/compare/${c.slug}`, + label: c.tagline, + sub: c.role, + })); + + return ( +
+
+ ); +} diff --git a/apps/web/src/app/guides/[slug]/page.tsx b/apps/web/src/app/guides/[slug]/page.tsx new file mode 100644 index 0000000..32f0cd9 --- /dev/null +++ b/apps/web/src/app/guides/[slug]/page.tsx @@ -0,0 +1,252 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import Link from 'next/link'; +import { Nav } from '@/components/layout/Nav'; +import { Footer } from '@/components/layout/Footer'; +import { Button, ArrowR } from '@/components/ui/Button'; +import { Eyebrow, AmbientArc } from '@/components/ui/primitives'; +import { GUIDES } from '@/lib/guides-data'; + +const SITE_URL = 'https://learnkit-ai.com'; + +export function generateStaticParams() { + return GUIDES.map((g) => ({ slug: g.slug })); +} + +export async function generateMetadata( + { params }: { params: Promise<{ slug: string }> }, +): Promise { + const { slug } = await params; + const guide = GUIDES.find((g) => g.slug === slug); + if (!guide) return {}; + + const title = guide.title; + const description = guide.intro.slice(0, 155); + + return { + title, + description, + keywords: guide.keywords, + alternates: { canonical: `/guides/${guide.slug}` }, + openGraph: { + type: 'article', + url: `${SITE_URL}/guides/${guide.slug}`, + title, + description, + siteName: 'LearnKit AI', + }, + twitter: { card: 'summary_large_image', title, description }, + }; +} + +export default async function GuidePage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const guide = GUIDES.find((g) => g.slug === slug); + if (!guide) notFound(); + + const related = GUIDES.filter((g) => g.slug !== guide.slug).slice(0, 3).map((g) => ({ + href: `/guides/${g.slug}`, + label: g.title, + sub: `${g.readingMinutes} min read`, + })); + + return ( +
+
+ ); +} diff --git a/apps/web/src/app/sitemap.ts b/apps/web/src/app/sitemap.ts index deebf8d..0edbd1a 100644 --- a/apps/web/src/app/sitemap.ts +++ b/apps/web/src/app/sitemap.ts @@ -1,5 +1,7 @@ import type { MetadataRoute } from 'next'; import { BLOG_POSTS } from '@/lib/blog-posts'; +import { COMPARISONS } from '@/lib/compare-data'; +import { GUIDES } from '@/lib/guides-data'; import { ROLES, SITE_URL, TOOLS } from '@/lib/seo-data'; export default function sitemap(): MetadataRoute.Sitemap { @@ -38,5 +40,19 @@ export default function sitemap(): MetadataRoute.Sitemap { priority: 0.65, })); - return [...core, ...toolPages, ...rolePages, ...blogPages]; + const comparePages: MetadataRoute.Sitemap = COMPARISONS.map((c) => ({ + url: `${SITE_URL}/compare/${c.slug}`, + lastModified: now, + changeFrequency: 'monthly', + priority: 0.7, + })); + + const guidePages: MetadataRoute.Sitemap = GUIDES.map((g) => ({ + url: `${SITE_URL}/guides/${g.slug}`, + lastModified: now, + changeFrequency: 'monthly', + priority: 0.75, + })); + + return [...core, ...toolPages, ...rolePages, ...blogPages, ...comparePages, ...guidePages]; } diff --git a/apps/web/src/components/ui/primitives.tsx b/apps/web/src/components/ui/primitives.tsx index 380dd1b..fb70f90 100644 --- a/apps/web/src/components/ui/primitives.tsx +++ b/apps/web/src/components/ui/primitives.tsx @@ -81,6 +81,11 @@ export function ToolIcon({ name, size = 22 }: { name: string; size?: number }) { 'Notion AI': { bg: '#111', glyph: 'N' }, Perplexity: { bg: '#1FB8CD', glyph: 'P' }, Gemini: { bg: '#4285F4', glyph: '✦' }, + Windsurf: { bg: '#0E7C7B', glyph: 'W' }, + Replit: { bg: '#F26207', glyph: 'R' }, + Linear: { bg: '#5E6AD2', glyph: 'L' }, + 'Figma AI': { bg: '#A259FF', glyph: 'F' }, + v0: { bg: '#000', glyph: 'v' }, }; const t = map[name] ?? { bg: 'var(--ink)', glyph: name?.[0] ?? '·' }; return ( diff --git a/apps/web/src/lib/compare-data.ts b/apps/web/src/lib/compare-data.ts new file mode 100644 index 0000000..83b99b6 --- /dev/null +++ b/apps/web/src/lib/compare-data.ts @@ -0,0 +1,242 @@ +export const SITE_URL = 'https://learnkit-ai.com'; + +export interface ComparePoint { + dimension: string; + a: string; + b: string; +} + +export interface ComparisonPage { + slug: string; + toolA: string; + toolB: string; + role: string; + tagline: string; + intro: string; + verdict: string; + points: ComparePoint[]; + keywords: string[]; +} + +export const COMPARISONS: ComparisonPage[] = [ + { + slug: 'claude-vs-chatgpt-for-engineers', + toolA: 'Claude', + toolB: 'ChatGPT', + role: 'Software Engineer', + tagline: 'Claude vs ChatGPT for Software Engineers', + intro: + 'Both Claude and ChatGPT can write, review, and explain code — but they make different trade-offs that matter when you are shipping production software. This comparison covers the dimensions engineers actually care about.', + verdict: + 'Claude tends to be stronger for large-codebase reasoning, structured output, and following constrained system prompts without drift. ChatGPT wins on ecosystem breadth, Code Interpreter for exploratory data work, and the GPT Store for pre-built task-specific models. Most senior engineers use both with purpose-built prompts rather than picking one.', + points: [ + { + dimension: 'Long-context code review', + a: 'Handles large files and multi-file diffs well. Consistent instruction-following across long contexts.', + b: 'Strong on shorter diffs. Can lose instruction fidelity in very long contexts.', + }, + { + dimension: 'Structured JSON output', + a: 'Highly reliable with explicit schema instructions. Rarely invents fields or omits required keys.', + b: 'Reliable with the JSON mode API. Verbose system prompts needed for complex schemas.', + }, + { + dimension: 'Exploratory data analysis', + a: 'No native code execution. Must paste results back in.', + b: 'Code Interpreter runs Python directly and returns charts and computed values in the same turn.', + }, + { + dimension: 'Tool use / function calling', + a: 'Excellent. Clear tool-use reasoning with low hallucination on tool arguments.', + b: 'Excellent. Well-tested in production with a large ecosystem of integrations.', + }, + { + dimension: 'System prompt adherence', + a: 'Very strong. Hard constraints in the system prompt are almost always respected across a session.', + b: 'Good but can soften constraints in long conversations. Requires more explicit re-anchoring.', + }, + ], + keywords: [ + 'claude vs chatgpt for engineers', + 'claude vs chatgpt coding', + 'best ai for software engineers', + 'claude chatgpt comparison code', + ], + }, + { + slug: 'cursor-vs-copilot-for-engineers', + toolA: 'Cursor', + toolB: 'Copilot', + role: 'Software Engineer', + tagline: 'Cursor vs GitHub Copilot for Software Engineers', + intro: + 'Cursor and GitHub Copilot both put AI inside your editor — but they are built on different premises about what AI-assisted development looks like. Copilot optimizes for autocomplete flow; Cursor optimizes for multi-file reasoning and agent-driven edits.', + verdict: + 'Cursor is the stronger choice for engineers who want to hand off multi-file tasks, run agents, and iterate on the codebase as a whole. Copilot is better embedded in GitHub workflows, has lower friction for teams that standardize on VSCode, and its Chat and PR Review features integrate directly with your GitHub Actions and pull request cycle.', + points: [ + { + dimension: 'Autocomplete quality', + a: 'Tab completion powered by the same underlying models, with full codebase context. Fast and context-aware.', + b: 'Industry-leading autocomplete trained on GitHub\'s corpus. Very accurate for common patterns.', + }, + { + dimension: 'Multi-file edits', + a: 'Composer can plan and execute changes across multiple files in a single session with explicit diff review.', + b: 'Copilot Edits supports multi-file changes but is more conservative in scope.', + }, + { + dimension: 'Codebase indexing', + a: 'Full repo index available to all prompts. Ask about any file without opening it.', + b: 'Workspace context in Chat. Requires @workspace prefix to pull in broader context.', + }, + { + dimension: 'GitHub integration', + a: 'No native GitHub integration. Stays in the editor.', + b: 'PR review, Copilot Workspace for issues-to-PRs, and Actions integration built in.', + }, + { + dimension: 'Agent mode', + a: 'Cursor Agent can run terminal commands, search the web, and iterate without returning control to you.', + b: 'Copilot agent is maturing. Best for smaller, well-scoped tasks.', + }, + ], + keywords: [ + 'cursor vs copilot', + 'cursor vs github copilot', + 'best ai code editor', + 'cursor copilot comparison', + ], + }, + { + slug: 'claude-vs-gemini-for-analysts', + toolA: 'Claude', + toolB: 'Gemini', + role: 'Data Analyst', + tagline: 'Claude vs Gemini for Data Analysts', + intro: + 'Data analysts need AI that handles long documents, reasons carefully about numbers, and integrates with the tools you already use. Claude and Gemini take different approaches to each of these.', + verdict: + 'Claude is stronger for careful, citation-honest analysis of long documents and complex datasets pasted into the context. Gemini wins on Google Workspace integration — if your org runs on Sheets, Docs, and BigQuery, Gemini in Workspace is already embedded in your workflow. For standalone analysis work, Claude\'s document reasoning is more reliable.', + points: [ + { + dimension: 'Long-document analysis', + a: 'Excellent. Can handle large PDFs and multi-document analysis with consistent extraction.', + b: 'Strong long context (up to 1M tokens). Best for very large corpora where the full document must be in context.', + }, + { + dimension: 'Google Workspace integration', + a: 'No native integration. Must copy content into Claude.', + b: 'Native in Docs, Sheets, Gmail, and BigQuery. Runs without leaving your tools.', + }, + { + dimension: 'Numeric reasoning', + a: 'Conservative and accurate. Flags uncertainty instead of inventing numbers.', + b: 'Good but occasionally more confident than warranted. Verify calculated outputs.', + }, + { + dimension: 'SQL and data pipelines', + a: 'Strong SQL generation with good schema awareness. Pairs well with pasted schema definitions.', + b: 'Strong SQL, with a specific advantage in BigQuery syntax via direct Workspace integration.', + }, + { + dimension: 'Hallucination rate on facts', + a: 'Lower than average. Prefers to say "I don\'t know" over inventing a citation.', + b: 'Good with Google Search grounding enabled. Without grounding, factual errors are possible.', + }, + ], + keywords: [ + 'claude vs gemini for data analysts', + 'claude vs gemini comparison', + 'best ai for data analysis', + 'gemini claude analyst', + ], + }, + { + slug: 'claude-vs-chatgpt-for-marketers', + toolA: 'Claude', + toolB: 'ChatGPT', + role: 'Marketer', + tagline: 'Claude vs ChatGPT for Marketers', + intro: + 'Marketers need AI that writes in a consistent brand voice, handles long briefs, and produces content that does not read like every other AI-generated campaign. Claude and ChatGPT make different bets on how to get there.', + verdict: + 'Claude is stronger for brand voice consistency and long-form content that follows tight editorial constraints. ChatGPT wins on DALL-E image generation, the GPT Store for specialist marketing workflows, and Browsing for real-time research. Most marketing teams use both: Claude for writing inside guardrails, ChatGPT for ideation, image generation, and research.', + points: [ + { + dimension: 'Brand voice consistency', + a: 'Excellent at internalising style guides and maintaining them across long documents.', + b: 'Good but requires more frequent re-anchoring in long sessions.', + }, + { + dimension: 'Email and ad copy', + a: 'Strong. Very good at following character limits, tone, and CTA constraints without reminders.', + b: 'Strong. Wide training on marketing formats. GPT Store has specialist marketing GPTs.', + }, + { + dimension: 'Real-time research', + a: 'No native browsing. Best with pasted content.', + b: 'Browsing via ChatGPT Plus. Good for competitive research and news synthesis.', + }, + { + dimension: 'Image generation', + a: 'No image generation.', + b: 'DALL-E 3 integrated. Good for campaign concept images and social assets.', + }, + { + dimension: 'Content repurposing at scale', + a: 'Very strong at following a repurposing template consistently across many assets.', + b: 'Strong, with the advantage of custom GPTs for repeatable repurposing workflows.', + }, + ], + keywords: [ + 'claude vs chatgpt for marketing', + 'best ai for marketers', + 'claude chatgpt marketing comparison', + 'ai content marketing tools', + ], + }, + { + slug: 'windsurf-vs-cursor-for-engineers', + toolA: 'Windsurf', + toolB: 'Cursor', + role: 'Software Engineer', + tagline: 'Windsurf vs Cursor for Software Engineers', + intro: + 'Both Windsurf and Cursor are AI-first IDEs built on VS Code\'s foundation — but they have different philosophies. Windsurf\'s Cascade model is trained for multi-step autonomous reasoning; Cursor\'s strength is in the explicitness of Composer and deep codebase indexing.', + verdict: + 'Windsurf\'s Cascade is more autonomous and better at planning multi-step changes without hand-holding. Cursor gives you more control over every step — better for engineers who want to review before applying. The best choice comes down to your trust level: high-trust autonomous agent or explicit pair-programmer. Most teams that switch from Copilot land on Cursor for familiarity, then graduate some engineers to Windsurf for larger refactors.', + points: [ + { + dimension: 'Autonomous task completion', + a: 'Cascade can plan and execute complex multi-step changes with less back-and-forth.', + b: 'Composer is strong but requires more explicit direction at each step.', + }, + { + dimension: 'Codebase understanding', + a: 'Deep repo indexing. Cascade can reference any file without being told.', + b: 'Full repo index. Similar depth with slightly different chunking strategy.', + }, + { + dimension: 'Diff review and control', + a: 'Diffs are shown but Cascade may apply multiple changes in one pass.', + b: 'Each change is staged for review before applying. More explicit control.', + }, + { + dimension: 'Ecosystem and plugins', + a: 'Newer ecosystem. Most VS Code extensions work but some edge cases exist.', + b: 'Larger extension compatibility and user community.', + }, + { + dimension: 'Pricing', + a: 'Free tier available. Pro plan comparable to Cursor.', + b: 'Free tier available. Pro plan comparable to Windsurf.', + }, + ], + keywords: [ + 'windsurf vs cursor', + 'windsurf cursor comparison', + 'best ai ide 2025', + 'windsurf cascade vs cursor composer', + ], + }, +]; diff --git a/apps/web/src/lib/guides-data.ts b/apps/web/src/lib/guides-data.ts new file mode 100644 index 0000000..0c6559e --- /dev/null +++ b/apps/web/src/lib/guides-data.ts @@ -0,0 +1,350 @@ +export interface GuideSection { + heading: string; + body: string; + code?: string; + lang?: string; +} + +export interface GuidePage { + slug: string; + title: string; + tagline: string; + intro: string; + readingMinutes: number; + sections: GuideSection[]; + keywords: string[]; +} + +export const GUIDES: GuidePage[] = [ + { + slug: 'embed-learnkit-in-nextjs', + title: 'Embed LearnKit AI in a Next.js app', + tagline: 'A step-by-step guide to adding role-aware learning paths to your Next.js product', + intro: + 'LearnKit AI is designed to drop into any SaaS product. This guide shows you the minimal integration: install the packages, render a learning path, and wire up the interactive demo — in under 30 minutes.', + readingMinutes: 8, + sections: [ + { + heading: 'Install the packages', + body: 'Add the React package and its peer dependency to your project. The core engine is bundled inside @learnkit-ai/react, so you only need one install for components.', + code: 'pnpm add @learnkit-ai/react @learnkit-ai/core', + lang: 'bash', + }, + { + heading: 'Render your first learning path', + body: 'The LearningPath component is a client component. Wrap it in a "use client" boundary if you are using the App Router, or render it inside a client component that already has the directive.', + code: `'use client'; +import { LearningPath } from '@learnkit-ai/react'; + +export function MyOnboarding() { + return ( + console.log('clicked', lesson.id)} + /> + ); +}`, + lang: 'tsx', + }, + { + heading: 'Use the hook for custom rendering', + body: 'If you want to build your own lesson UI, use the useLearnKit hook directly. It returns the full LearningPath object — all four weeks, every lesson, total minutes — which you can render however you like.', + code: `import { useLearnKit } from '@learnkit-ai/react'; + +export function CustomPath({ role }: { role: string }) { + const { path, error } = useLearnKit({ + role, + tools: ['Claude'], + goal: 'Automate my weekly report', + level: 'intermediate', + }); + + if (error) return
Error: {error.message}
; + if (!path) return null; + + return ( +
    + {path.weeks.flatMap((w) => w.lessons).map((l) => ( +
  • {l.title} — {l.minutes}m
  • + ))} +
+ ); +}`, + lang: 'tsx', + }, + { + heading: 'Add progress tracking', + body: 'Drop in ProgressTracker instead of LearningPath when you want lessons to remember their completion state across page refreshes. Progress is stored in localStorage under lk-progress-{pathId}.', + code: `import { ProgressTracker } from '@learnkit-ai/react'; + +export function OnboardingWithProgress() { + return ( + + ); +}`, + lang: 'tsx', + }, + { + heading: 'Pass a companyContext for personalised paths', + body: 'The optional companyContext field (max 500 chars) lets you inject stack and team information. The engine uses it to personalise project lesson summaries — no AI call required.', + code: ``, + lang: 'tsx', + }, + ], + keywords: [ + 'learnkit ai nextjs integration', + 'embed learning path nextjs', + 'learnkit react tutorial', + 'ai learning path saas integration', + ], + }, + { + slug: 'theming-and-custom-styles', + title: 'Theming and custom styles', + tagline: 'Use CSS custom properties to match LearnKit AI to any host design system', + intro: + 'LearnKit AI components use CSS custom properties for every visual decision — colour, typography, spacing. This means you can override any part of the default theme by setting variables on a wrapper element, without writing component-specific CSS.', + readingMinutes: 6, + sections: [ + { + heading: 'The built-in themes', + body: 'The LearningPath and ProgressTracker components ship with three themes: warm (default), midnight, and technical. Pass the theme prop to switch between them.', + code: ` +`, + lang: 'tsx', + }, + { + heading: 'Override with CSS custom properties', + body: 'Every colour token is a CSS custom property. Set them on the component or a parent wrapper to override the theme. All properties use the --lk- prefix.', + code: `.my-wrapper { + --lk-surface: #F7F5F0; + --lk-ink: #1A1A1A; + --lk-accent: #6B4EFF; + --lk-accent-3: #2D9B6E; + --lk-font-sans: 'Inter', system-ui, sans-serif; + --lk-font-serif: 'Playfair Display', Georgia, serif; + --lk-font-mono: 'JetBrains Mono', monospace; +}`, + lang: 'css', + }, + { + heading: 'Full token reference', + body: 'These are all the tokens the components read. Any unset token falls back to the default warm theme value.', + code: `--lk-surface /* card and component background */ +--lk-ink /* primary text */ +--lk-ink-soft /* secondary text */ +--lk-muted /* captions, labels */ +--lk-accent /* primary accent (in-progress indicator, CTA) */ +--lk-accent-2 /* secondary accent */ +--lk-accent-3 /* completed indicator */ +--lk-rule /* subtle borders */ +--lk-rule-strong /* prominent borders */ +--lk-font-sans /* body typeface */ +--lk-font-serif /* heading typeface */ +--lk-font-mono /* monospace / labels */`, + lang: 'css', + }, + { + heading: 'Using inline styles for one-off overrides', + body: 'You can also set variables via the style prop on the component itself. This is useful for per-instance theming without a CSS class.', + code: ``, + lang: 'tsx', + }, + ], + keywords: [ + 'learnkit ai theming', + 'learnkit css custom properties', + 'custom ui learning path', + 'learnkit design system integration', + ], + }, + { + slug: 'building-with-the-cli', + title: 'Building with the LearnKit AI CLI', + tagline: 'Use the CLI to generate learning paths in scripts, pipelines, and development workflows', + intro: + 'The @learnkit-ai/cli package gives you the full generateLearningPath engine on the command line. Use it to prototype paths, inspect curricula, and pipe structured JSON into your own tooling — all without writing any code.', + readingMinutes: 5, + sections: [ + { + heading: 'Install globally or run with npx', + body: 'You can install the CLI globally or run it on demand with npx. The CLI has no external dependencies beyond Node.js >=20.', + code: `# install globally +pnpm add -g @learnkit-ai/cli + +# or run without installing +npx @learnkit-ai/cli generate --role "Software Engineer" --tools Claude --goal "Build an agent"`, + lang: 'bash', + }, + { + heading: 'Generate a learning path', + body: 'The generate command takes role, tools, goal, and level. It prints a human-readable path by default.', + code: `learnkit-ai generate \\ + --role "Product Manager" \\ + --tools "Claude,Notion AI" \\ + --goal "Ship a research agent" \\ + --level intermediate`, + lang: 'bash', + }, + { + heading: 'Get JSON output', + body: 'Add --output json to get the full LearningPath object as JSON. Pipe it into jq, store it in a file, or pass it to another script.', + code: `learnkit-ai generate \\ + --role "Software Engineer" \\ + --tools Cursor \\ + --goal "Refactor a legacy codebase" \\ + --level advanced \\ + --output json | jq '.weeks[0].lessons[].title'`, + lang: 'bash', + }, + { + heading: 'Pass company context', + body: 'Use --company-context to personalise project lessons for a specific stack or team size.', + code: `learnkit-ai generate \\ + --role "Founder" \\ + --tools "Claude,Cursor" \\ + --goal "Replace my ops team with agents" \\ + --level beginner \\ + --company-context "5-person team, shipping weekly, TypeScript"`, + lang: 'bash', + }, + { + heading: 'List supported roles and tools', + body: 'Use the roles and tools subcommands to see everything the engine supports.', + code: `learnkit-ai roles +learnkit-ai tools`, + lang: 'bash', + }, + ], + keywords: [ + 'learnkit ai cli', + 'learnkit cli tutorial', + 'generate learning path cli', + 'npx learnkit ai', + ], + }, + { + slug: 'generating-lesson-content', + title: 'Generating full lesson content', + tagline: 'Use generateLessonContent to produce exercises, rubrics, and lesson bodies', + intro: + 'Every lesson in a LearnKit AI path includes a title, summary, tool, and duration. The generateLessonContent function expands any lesson into a full body text, two exercises, and a rubric — all deterministic, all pure, no API calls.', + readingMinutes: 5, + sections: [ + { + heading: 'Import and call the function', + body: 'generateLessonContent takes a Lesson object (from generateLearningPath) and returns a LessonContent object. It is synchronous and pure — the same lesson always returns the same content.', + code: `import { generateLearningPath, generateLessonContent } from '@learnkit-ai/core'; + +const path = generateLearningPath({ + role: 'Software Engineer', + tools: ['Claude'], + goal: 'Ship an AI-assisted code review tool', + level: 'intermediate', +}); + +const firstLesson = path.weeks[0]!.lessons[0]!; +const content = generateLessonContent(firstLesson); + +console.log(content.body); +// → multi-paragraph lesson body +console.log(content.exercises[0]!.prompt); +// → exercise prompt text +console.log(content.rubric[0]!.criterion); +// → "Technique application"`, + lang: 'ts', + }, + { + heading: 'The LessonContent shape', + body: 'The return type is validated by the LessonContentSchema. You can import the type from either @learnkit-ai/core or @learnkit-ai/schemas.', + code: `import type { LessonContent, Exercise, RubricItem } from '@learnkit-ai/core'; + +interface LessonContent { + lessonId: string; // matches lesson.id + body: string; // 2-paragraph lesson expansion + exercises: Exercise[]; // 2 exercises + rubric: RubricItem[]; // 2 rubric criteria +} + +interface Exercise { + prompt: string; // what the learner should do + expectedOutput: string; // what a good response looks like + rubricHint: string; // evaluation tip for the reviewer +} + +interface RubricItem { + criterion: string; // what is being evaluated + excellent: string; // description of excellent work + acceptable: string; // description of acceptable work + needsWork: string; // description of work that needs improvement +}`, + lang: 'ts', + }, + { + heading: 'Render it in React', + body: 'Combine generateLessonContent with the useLearnKit hook or LearningPath component to build a full lesson detail view.', + code: `'use client'; +import { useState } from 'react'; +import { generateLessonContent } from '@learnkit-ai/core'; +import { LearningPath } from '@learnkit-ai/react'; +import type { Lesson } from '@learnkit-ai/schemas'; + +export function PathWithLessonDetail({ input }) { + const [activeLesson, setActiveLesson] = useState(null); + const content = activeLesson ? generateLessonContent(activeLesson) : null; + + return ( +
+ + {content && ( +
+

{content.body}

+ {content.exercises.map((ex, i) => ( +
+ Exercise {i + 1}: {ex.prompt} +
+ ))} +
+ )} +
+ ); +}`, + lang: 'tsx', + }, + ], + keywords: [ + 'learnkit generate lesson content', + 'learnkit exercises rubric', + 'lesson content api learnkit', + 'generateLessonContent tutorial', + ], + }, +]; diff --git a/apps/web/src/lib/seo-data.ts b/apps/web/src/lib/seo-data.ts index 5e9249b..11ac79c 100644 --- a/apps/web/src/lib/seo-data.ts +++ b/apps/web/src/lib/seo-data.ts @@ -98,6 +98,66 @@ export const TOOLS = [ color: '#4285F4', keywords: ['gemini tutorial', 'learn google gemini', 'gemini api training'], }, + { + slug: 'windsurf', + name: 'Windsurf', + vendor: 'Codeium', + tagline: 'Windsurf: the AI IDE that writes and reasons', + blurb: + 'Flows, Cascade, and multi-file reasoning — Windsurf goes beyond autocomplete to understand your codebase and make decisions across files.', + modules: 5, + hours: 9, + color: '#0E7C7B', + keywords: ['windsurf ai ide', 'codeium windsurf tutorial', 'learn windsurf', 'windsurf cascade'], + }, + { + slug: 'replit', + name: 'Replit', + vendor: 'Replit', + tagline: 'Replit AI: build and ship from the browser', + blurb: + 'Replit Agent, Ghostwriter, and deployments — prototype, iterate, and ship full-stack apps without a local environment.', + modules: 4, + hours: 7, + color: '#F26207', + keywords: ['replit ai tutorial', 'replit agent training', 'learn replit', 'replit ghostwriter'], + }, + { + slug: 'linear', + name: 'Linear', + vendor: 'Linear', + tagline: 'Linear with AI: ship faster without process overhead', + blurb: + 'Linear\'s AI issue creation, triage, and workflow automation — so your team spends time building, not managing tickets.', + modules: 3, + hours: 5, + color: '#5E6AD2', + keywords: ['linear ai tutorial', 'linear project management ai', 'learn linear', 'linear automation'], + }, + { + slug: 'figma-ai', + name: 'Figma AI', + vendor: 'Figma', + tagline: 'Figma AI for design teams that move fast', + blurb: + 'First Draft, Auto Layout with AI, and Make Designs — use Figma\'s built-in AI features to explore more, polish faster, and stay in the file.', + modules: 4, + hours: 6, + color: '#A259FF', + keywords: ['figma ai tutorial', 'figma first draft', 'learn figma ai', 'figma make designs'], + }, + { + slug: 'v0', + name: 'v0', + vendor: 'Vercel', + tagline: 'v0 by Vercel: generate production UI in seconds', + blurb: + 'From text prompt to deployable React component — v0 turns design intent into working code you can edit, deploy, and own.', + modules: 3, + hours: 5, + color: '#000000', + keywords: ['v0 vercel tutorial', 'v0 ai ui generator', 'learn v0', 'vercel v0 components'], + }, ] as const; export type ToolSlug = (typeof TOOLS)[number]['slug']; @@ -215,6 +275,48 @@ export const ROLES = [ ], tools: ['Claude', 'Perplexity', 'ChatGPT'], }, + { + slug: 'sales', + name: 'Sales', + tagline: 'AI training for Sales professionals', + blurb: + 'Research accounts in minutes, write outreach that gets replies, and cut proposal time in half — without losing the human touch that closes deals.', + skills: [ + 'Account research and call prep at scale', + 'Personalised outbound that does not sound like AI', + 'CRM notes and follow-ups in under two minutes', + 'Proposals and objection handling with AI drafts', + ], + tools: ['Claude', 'ChatGPT', 'Perplexity'], + }, + { + slug: 'customer-success', + name: 'Customer Success', + tagline: 'AI training for Customer Success managers', + blurb: + 'Spot churn before it happens, build onboarding plans in minutes, and run QBRs that actually lead to expansion.', + skills: [ + 'AI-assisted onboarding plans that fit each customer', + 'Churn signal detection from account data and call notes', + 'QBR prep and narrative in half the usual time', + 'Expansion opportunity identification at scale', + ], + tools: ['Claude', 'ChatGPT', 'Notion AI'], + }, + { + slug: 'finance', + name: 'Finance', + tagline: 'AI training for Finance professionals', + blurb: + 'Turn raw numbers into board-ready narratives, automate variance commentary, and stress-test models before the CFO does.', + skills: [ + 'Financial model documentation and assumption logging', + 'Variance commentary that writes itself from actuals', + 'Scenario analysis packages leadership can act on', + 'Risk and contract analysis without a legal review queue', + ], + tools: ['Claude', 'ChatGPT', 'Gemini'], + }, ] as const; export type RoleSlug = (typeof ROLES)[number]['slug']; diff --git a/packages/core/src/__tests__/generate.test.ts b/packages/core/src/__tests__/generate.test.ts index 8424da1..c9aef2f 100644 --- a/packages/core/src/__tests__/generate.test.ts +++ b/packages/core/src/__tests__/generate.test.ts @@ -76,6 +76,9 @@ describe('generateLearningPath', () => { 'Founder', 'Operations', 'Researcher', + 'Sales', + 'Customer Success', + 'Finance', ])('generates a valid four-week path for %s', (role) => { const path = generateLearningPath({ ...SAMPLE, role }); expect(path.weeks).toHaveLength(4); @@ -85,7 +88,7 @@ describe('generateLearningPath', () => { }); it('generates different paths for different levels', () => { - const roles = ['Marketer', 'Founder', 'Operations', 'Researcher']; + const roles = ['Marketer', 'Founder', 'Operations', 'Researcher', 'Sales', 'Customer Success', 'Finance']; for (const role of roles) { const beg = generateLearningPath({ ...SAMPLE, role, level: 'beginner' }); const adv = generateLearningPath({ ...SAMPLE, role, level: 'advanced' }); diff --git a/packages/core/src/__tests__/lesson-content.test.ts b/packages/core/src/__tests__/lesson-content.test.ts new file mode 100644 index 0000000..cc56222 --- /dev/null +++ b/packages/core/src/__tests__/lesson-content.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import { generateLearningPath, generateLessonContent } from '../index'; +import type { LearningPathInput } from '@learnkit-ai/schemas'; + +const SAMPLE: LearningPathInput = { + role: 'Software Engineer', + tools: ['Claude'], + goal: 'Build an AI code review tool', + level: 'intermediate', +}; + +function getFirstLesson() { + const path = generateLearningPath(SAMPLE); + return path.weeks[0]!.lessons[0]!; +} + +describe('generateLessonContent', () => { + it('returns a LessonContent with the correct lessonId', () => { + const lesson = getFirstLesson(); + const content = generateLessonContent(lesson); + expect(content.lessonId).toBe(lesson.id); + }); + + it('returns a non-empty body string', () => { + const lesson = getFirstLesson(); + const content = generateLessonContent(lesson); + expect(content.body.length).toBeGreaterThan(50); + }); + + it('returns at least one exercise', () => { + const lesson = getFirstLesson(); + const content = generateLessonContent(lesson); + expect(content.exercises.length).toBeGreaterThanOrEqual(1); + }); + + it('returns at least one rubric item', () => { + const lesson = getFirstLesson(); + const content = generateLessonContent(lesson); + expect(content.rubric.length).toBeGreaterThanOrEqual(1); + }); + + it('is deterministic — same lesson always returns the same content', () => { + const lesson = getFirstLesson(); + const a = generateLessonContent(lesson); + const b = generateLessonContent(lesson); + expect(JSON.stringify(a)).toBe(JSON.stringify(b)); + }); + + it('body references the lesson tool', () => { + const lesson = getFirstLesson(); + const content = generateLessonContent(lesson); + expect(content.body.toLowerCase()).toContain('claude'); + }); + + it('generates content for project lessons', () => { + const path = generateLearningPath(SAMPLE); + const project = path.weeks.flatMap((w) => w.lessons).find((l) => l.kind === 'project'); + expect(project).toBeDefined(); + const content = generateLessonContent(project!); + expect(content.body).toMatch(/deliver|ship|project/i); + expect(content.exercises[0]!.prompt).toMatch(/brief|project/i); + }); + + it('generates content for practicum lessons', () => { + const path = generateLearningPath(SAMPLE); + const practicum = path.weeks.flatMap((w) => w.lessons).find((l) => l.kind === 'practicum'); + expect(practicum).toBeDefined(); + const content = generateLessonContent(practicum!); + expect(content.body).toMatch(/portfolio|practicum/i); + }); + + it('each exercise has prompt, expectedOutput, and rubricHint', () => { + const lesson = getFirstLesson(); + const content = generateLessonContent(lesson); + for (const ex of content.exercises) { + expect(ex.prompt.length).toBeGreaterThan(10); + expect(ex.expectedOutput.length).toBeGreaterThan(10); + expect(ex.rubricHint.length).toBeGreaterThan(10); + } + }); + + it('each rubric item has excellent, acceptable, and needsWork', () => { + const lesson = getFirstLesson(); + const content = generateLessonContent(lesson); + for (const item of content.rubric) { + expect(item.criterion.length).toBeGreaterThan(0); + expect(item.excellent.length).toBeGreaterThan(10); + expect(item.acceptable.length).toBeGreaterThan(10); + expect(item.needsWork.length).toBeGreaterThan(10); + } + }); +}); diff --git a/packages/core/src/generate.ts b/packages/core/src/generate.ts index c297c47..140df77 100644 --- a/packages/core/src/generate.ts +++ b/packages/core/src/generate.ts @@ -740,6 +740,252 @@ const ROLE_WEEKS: Partial> = { }, GENERIC_WEEKS[3]!, ], + + 'Sales': [ + { + title: 'Prospecting & outreach', + templates: [ + { + title: () => 'Researching accounts at scale with AI', + summary: ({ tool }) => + `Use ${tool} to build an account brief in minutes: company priorities, recent news, likely pain points, and a hook that is actually relevant to their world.`, + minutes: 16, + kind: 'lesson', + }, + { + title: () => 'Writing cold outreach that gets replies', + summary: ({ tool }) => + `Build a ${tool} prompt that generates personalised first-touch emails — not templates with [NAME] placeholders, but messages that reference what the buyer actually cares about.`, + minutes: 14, + kind: 'lesson', + }, + { + title: () => 'Project: outbound sequence for one target segment', + summary: ({ tool, goal }) => + `Build a full outbound sequence for one ICP segment using ${tool}: account research prompt, first-touch email, follow-up cadence, and a voicemail script. Goal: ${goal}.`, + minutes: 35, + kind: 'project', + }, + ], + }, + { + title: 'Discovery & qualification', + templates: [ + { + title: () => 'Preparing for discovery calls with AI', + summary: ({ tool }) => + `Use ${tool} to generate a call prep brief: the five questions you must ask, the red flags to watch for, and a hypothesis about the buyer's top priority before you dial.`, + minutes: 14, + kind: 'lesson', + }, + { + title: () => 'Turning call notes into structured CRM updates', + summary: ({ tool }) => + `Paste raw call notes into ${tool} and get back a structured MEDDIC or BANT summary, next steps, and a draft follow-up email — in under 90 seconds.`, + minutes: 16, + kind: 'lesson', + }, + { + title: () => 'Project: AI-assisted deal qualification scorecard', + summary: ({ goal }) => + `Build a deal qualification prompt that scores an opportunity against your ICP criteria and surfaces the one question you still need to answer. Goal: ${goal}.`, + minutes: 40, + kind: 'project', + }, + ], + }, + { + title: 'Closing & follow-up', + templates: [ + { + title: () => 'Writing proposals that move faster', + summary: ({ tool }) => + `Use ${tool} to draft proposal sections: executive summary, business case, and ROI model. Get a first draft that frames the value in the buyer's language, not yours.`, + minutes: 20, + kind: 'lesson', + }, + { + title: () => 'Handling objections with AI-prepared responses', + summary: ({ tool }) => + `Feed your most common objections into ${tool} and build a bank of responses with the evidence, the reframe, and the next question. Review and own every word before you use it.`, + minutes: 16, + kind: 'lesson', + }, + { + title: () => 'Project: end-to-end deal support package', + summary: ({ goal }) => + `Build a full deal support package for one active opportunity: account brief, discovery questions, objection bank, and a draft proposal intro — all AI-assisted. Goal: ${goal}.`, + minutes: 50, + kind: 'project', + }, + ], + }, + GENERIC_WEEKS[3]!, + ], + + 'Customer Success': [ + { + title: 'Onboarding & activation', + templates: [ + { + title: () => 'Building personalised onboarding plans with AI', + summary: ({ tool }) => + `Use ${tool} to generate a customer-specific onboarding plan: milestones mapped to the customer's stated goals, the right sequence, and the first-week checklist that actually lands.`, + minutes: 16, + kind: 'lesson', + }, + { + title: () => 'Writing success plans customers keep open', + summary: ({ tool }) => + `Use ${tool} to draft a success plan that speaks the customer's language, not your internal jargon. Define milestones in terms of outcomes the customer can measure, not features they will unlock.`, + minutes: 14, + kind: 'lesson', + }, + { + title: () => 'Project: AI-assisted onboarding kit', + summary: ({ tool, goal }) => + `Build a reusable onboarding kit for one customer segment using ${tool}: welcome email, day-one checklist, 30-day success plan, and a champion enablement guide. Goal: ${goal}.`, + minutes: 35, + kind: 'project', + }, + ], + }, + { + title: 'Health monitoring & churn prevention', + templates: [ + { + title: () => 'Spotting churn signals in customer data', + summary: ({ tool }) => + `Use ${tool} to analyse usage notes, support tickets, and sentiment from calls. Build a prompt that surfaces the one or two signals that precede churn in your accounts.`, + minutes: 18, + kind: 'lesson', + }, + { + title: () => 'Writing at-risk outreach that re-engages', + summary: ({ tool }) => + `Use ${tool} to draft outreach for at-risk accounts: acknowledge the gap, offer a concrete next step, and avoid the "just checking in" trap. Test it on a real account before sending.`, + minutes: 14, + kind: 'lesson', + }, + { + title: () => 'Project: customer health review workflow', + summary: ({ goal }) => + `Build a monthly customer health review workflow: a prompt that turns account data into a one-page health summary with risk flags and recommended actions. Goal: ${goal}.`, + minutes: 45, + kind: 'project', + }, + ], + }, + { + title: 'QBRs, expansion & advocacy', + templates: [ + { + title: () => 'Preparing QBR materials in half the time', + summary: ({ tool }) => + `Use ${tool} to draft the QBR narrative: what happened, what it means for the customer's goals, and what you recommend next — structured and editable before the meeting.`, + minutes: 20, + kind: 'lesson', + }, + { + title: () => 'Identifying expansion opportunities in account data', + summary: ({ tool }) => + `Use ${tool} to scan your account notes and identify gaps between what the customer is using and what they could be using — surfacing natural expansion conversations.`, + minutes: 16, + kind: 'lesson', + }, + { + title: () => 'Project: QBR pack for one strategic account', + summary: ({ goal }) => + `Build a full QBR pack for one account: executive summary, metrics narrative, risk and opportunity analysis, and the three things you want to leave the room having agreed on. Goal: ${goal}.`, + minutes: 50, + kind: 'project', + }, + ], + }, + GENERIC_WEEKS[3]!, + ], + + 'Finance': [ + { + title: 'Analysis & modelling', + templates: [ + { + title: () => 'Using AI to build and stress-test financial models', + summary: ({ tool }) => + `Use ${tool} to draft model structures, check formula logic, and document assumptions — so the model survives the next person who opens it, not just the person who built it.`, + minutes: 18, + kind: 'lesson', + }, + { + title: () => 'Turning raw data into executive-ready analysis', + summary: ({ tool }) => + `Give ${tool} a table of numbers and a business question. Get back the three-sentence story, the table that supports it, and the caveat the CFO will ask about — drafted in minutes.`, + minutes: 16, + kind: 'lesson', + }, + { + title: () => 'Project: AI-assisted financial model documentation', + summary: ({ tool, goal }) => + `Take a live model and use ${tool} to write a model guide: assumptions, sensitivities, data sources, and version history. Goal: ${goal}.`, + minutes: 35, + kind: 'project', + }, + ], + }, + { + title: 'Reporting & forecasting', + templates: [ + { + title: () => 'Building a repeatable monthly close narrative', + summary: ({ tool }) => + `Use ${tool} to generate the monthly close narrative from your actuals vs budget data. Define the template once, feed it numbers each month, and get a first draft in minutes.`, + minutes: 20, + kind: 'lesson', + }, + { + title: () => 'AI-assisted variance analysis', + summary: ({ tool }) => + `Use ${tool} to draft variance commentary: what drove the delta, whether it is a timing issue or a real miss, and what it implies for the rest of the year. Edit for accuracy, not for words.`, + minutes: 16, + kind: 'lesson', + }, + { + title: () => 'Project: automated board reporting narrative', + summary: ({ goal }) => + `Build a prompt that turns your monthly financial data into a board-ready narrative: headline performance, key variances, updated full-year outlook, and the one decision the board needs to make. Goal: ${goal}.`, + minutes: 45, + kind: 'project', + }, + ], + }, + { + title: 'Risk, compliance & FP&A', + templates: [ + { + title: () => 'Using AI to flag risks in contracts and filings', + summary: ({ tool }) => + `Use ${tool} to extract key obligations, risk clauses, and financial covenants from contracts or regulatory filings. Build a prompt that surfaces the items your team must act on.`, + minutes: 18, + kind: 'lesson', + }, + { + title: () => 'FP&A scenario modelling with AI', + summary: ({ tool }) => + `Use ${tool} to generate and document multiple forecast scenarios: base, upside, and downside — each with the assumptions and the metric impacts written in language leadership can read.`, + minutes: 20, + kind: 'lesson', + }, + { + title: () => 'Project: scenario analysis package', + summary: ({ goal }) => + `Build a scenario analysis package for a live business decision: three scenarios, key assumptions, financial impact, and a recommendation memo — drafted with AI, validated by you. Goal: ${goal}.`, + minutes: 50, + kind: 'project', + }, + ], + }, + GENERIC_WEEKS[3]!, + ], }; // --------------------------------------------------------------------------- diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dee631a..fa85988 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,6 +4,7 @@ */ export { generateLearningPath } from './generate'; +export { generateLessonContent } from './lesson-content'; export { getSupportedRoles, isRoleSupported, @@ -18,14 +19,22 @@ export { type SupportedTool, } from './tools'; export { + ExerciseSchema, LearningPathInputSchema, + LearningPathProgressSchema, LearningPathSchema, + LessonContentSchema, LessonSchema, LevelSchema, + RubricItemSchema, + type Exercise, type LearningPath, type LearningPathInput, + type LearningPathProgress, type Lesson, + type LessonContent, type LessonKind, type Level, + type RubricItem, type Week, } from '@learnkit-ai/schemas'; diff --git a/packages/core/src/lesson-content.ts b/packages/core/src/lesson-content.ts new file mode 100644 index 0000000..14be93e --- /dev/null +++ b/packages/core/src/lesson-content.ts @@ -0,0 +1,148 @@ +import { + LessonContentSchema, + LessonSchema, + type Exercise, + type Lesson, + type LessonContent, + type RubricItem, +} from '@learnkit-ai/schemas'; + +function buildBody(lesson: Lesson): string { + const { title, summary, tool, kind, minutes } = lesson; + + if (kind === 'practicum') { + return ( + `${summary}\n\n` + + `This practicum is evaluated on the depth and honesty of your portfolio. ` + + `Submit three artifacts that show a complete workflow — from initial prompt to final output — along with your own commentary on what worked, what you edited, and why. ` + + `The goal is not a polished showcase but an accurate record of how you think with ${tool}.` + ); + } + + if (kind === 'project') { + return ( + `${summary}\n\n` + + `Deliver a working output by the end of the week. Scope it so it is shareable — something a colleague could pick up and use without your explanation. ` + + `Document the prompt that produced it, the edits you made, and the one thing you would change with more time. ` + + `Projects in this curriculum are designed to take approximately ${minutes} minutes including iteration.` + ); + } + + return ( + `${summary}\n\n` + + `Work through this lesson by trying each step yourself before reading ahead. ` + + `The lesson is scoped to ${minutes} minutes including hands-on practice with ${tool}. ` + + `By the end you should be able to ${title.slice(0, 1).toLowerCase()}${title.slice(1)} in your own context, ` + + `without referring back to these notes.` + ); +} + +function buildExercises(lesson: Lesson): Exercise[] { + const { tool, kind, title } = lesson; + + if (kind === 'practicum') { + return [ + { + prompt: `Draft an outline of the three portfolio artifacts you will submit. For each, describe the workflow it demonstrates, the tool used, and how you will evaluate its quality.`, + expectedOutput: `A structured list of three artifacts, each with: title, tool, workflow description, and at least one measurable success criterion.`, + rubricHint: `Each artifact should target a distinct skill from the course — overlapping coverage is a sign the scope is too narrow.`, + }, + { + prompt: `Write the commentary for your strongest artifact. Explain what you prompted, what the model returned, and what edits you made — and why.`, + expectedOutput: `A 200–400 word annotation covering: initial prompt, model output, edits made, and a reflection on the gap between the first draft and the final output.`, + rubricHint: `Strong commentary explains decisions, not just actions. "I changed X because Y" beats "I edited the output."`, + }, + ]; + } + + if (kind === 'project') { + return [ + { + prompt: `Before starting the project, write a one-paragraph brief: what you are building, who it is for, and how you will know it is done.`, + expectedOutput: `A project brief with: goal statement, intended audience, at least two success criteria, and a rough delivery timeline.`, + rubricHint: `Success criteria should be specific enough to evaluate objectively — "it works" does not count.`, + }, + { + prompt: `After completing the project, write a 3-bullet retrospective: what worked, what you would do differently, and what you learned about using ${tool} for this type of task.`, + expectedOutput: `Three concise bullet points covering retrospective observations. Each should reference a specific prompt decision or output — avoid vague statements like "it was useful."`, + rubricHint: `Retrospectives that reference specific prompt decisions are stronger than general reflections.`, + }, + ]; + } + + return [ + { + prompt: `Apply the technique from "${title}" to a real task from your own work. Document the prompt you used and the output you got.`, + expectedOutput: `A before/after record: the task description, the ${tool} prompt you wrote, the output you received, and a one-sentence verdict on whether it met your standard.`, + rubricHint: `Use a real task, not a fabricated one — specificity makes the exercise useful to you and reviewable by others.`, + }, + { + prompt: `Identify one way the technique from this lesson could fail in your context. Write a prompt that deliberately tries to trigger that failure, then write a revised prompt that prevents it.`, + expectedOutput: `A failure-mode description, a prompt that demonstrates it, the problematic output, a revised prompt, and the improved output.`, + rubricHint: `Understanding failure modes is more transferable than demonstrating success — a reviewer can tell whether you actually tried to break it.`, + }, + ]; +} + +function buildRubric(lesson: Lesson): RubricItem[] { + const { kind, tool } = lesson; + + if (kind === 'practicum') { + return [ + { + criterion: 'Portfolio depth', + excellent: `Three artifacts each demonstrate a distinct, non-trivial skill with full prompt-to-output documentation and honest commentary.`, + acceptable: `Three artifacts are present but overlap in skill area, or lack full documentation of the prompt design.`, + needsWork: `Fewer than three artifacts, or artifacts that are superficial demonstrations without evidence of real application.`, + }, + { + criterion: 'Reflection quality', + excellent: `Commentary explains specific prompt decisions and their trade-offs, referencing techniques from the course.`, + acceptable: `Commentary describes what was done but does not explain why decisions were made.`, + needsWork: `Commentary is absent, purely descriptive, or limited to "it worked well."`, + }, + ]; + } + + if (kind === 'project') { + return [ + { + criterion: 'Deliverable quality', + excellent: `A real, shareable artifact that solves the stated problem and could be used by the intended audience immediately.`, + acceptable: `A draft artifact that addresses the problem but requires significant editing before use.`, + needsWork: `An incomplete artifact, or one that does not address the stated problem.`, + }, + { + criterion: 'Prompt design', + excellent: `The prompt is structured, documented, and repeatable — someone else could run it and get consistent results.`, + acceptable: `The prompt works but is not documented or is difficult to reproduce.`, + needsWork: `Output was edited heavily to compensate for a weak prompt, without revisiting the prompt itself.`, + }, + ]; + } + + return [ + { + criterion: 'Technique application', + excellent: `Applied the technique to a real task, with a documented ${tool} prompt and output that clearly demonstrates the concept from this lesson.`, + acceptable: `Applied the technique but to a simplified or fabricated task that does not reflect real work.`, + needsWork: `Described the technique without applying it, or produced output without showing the prompt that generated it.`, + }, + { + criterion: 'Failure analysis', + excellent: `Identified a specific, realistic failure mode and produced a revised prompt that demonstrably addresses it.`, + acceptable: `Identified a failure mode but the revised prompt only partially addresses it.`, + needsWork: `No failure mode identified, or the analysis is too vague to be actionable.`, + }, + ]; +} + +export function generateLessonContent(rawLesson: Lesson): LessonContent { + const lesson = LessonSchema.parse(rawLesson); + return LessonContentSchema.parse({ + lessonId: lesson.id, + body: buildBody(lesson), + exercises: buildExercises(lesson), + rubric: buildRubric(lesson), + }); +} diff --git a/packages/core/src/roles.ts b/packages/core/src/roles.ts index a3a1c4a..e4dcca4 100644 --- a/packages/core/src/roles.ts +++ b/packages/core/src/roles.ts @@ -7,6 +7,9 @@ export const SUPPORTED_ROLES = [ 'Founder', 'Operations', 'Researcher', + 'Sales', + 'Customer Success', + 'Finance', ] as const; export type SupportedRole = (typeof SUPPORTED_ROLES)[number]; @@ -33,6 +36,20 @@ const ROLE_ALIASES: Record = { operations: 'Operations', researcher: 'Researcher', research: 'Researcher', + sales: 'Sales', + ae: 'Sales', + sdr: 'Sales', + bdr: 'Sales', + 'account executive': 'Sales', + 'customer success': 'Customer Success', + 'customer-success': 'Customer Success', + csm: 'Customer Success', + 'account manager': 'Customer Success', + finance: 'Finance', + cfo: 'Finance', + 'financial analyst': 'Finance', + fp: 'Finance', + 'fp&a': 'Finance', }; export function normalizeRole(role: string): SupportedRole { diff --git a/packages/core/src/tools.ts b/packages/core/src/tools.ts index d810346..0f60bf3 100644 --- a/packages/core/src/tools.ts +++ b/packages/core/src/tools.ts @@ -7,6 +7,11 @@ export const SUPPORTED_TOOLS = [ 'Notion AI', 'Perplexity', 'Gemini', + 'Windsurf', + 'Replit', + 'Linear', + 'Figma AI', + 'v0', ] as const; export type SupportedTool = (typeof SUPPORTED_TOOLS)[number]; @@ -29,6 +34,14 @@ const TOOL_ALIASES: Record = { gemini: 'Gemini', google: 'Gemini', bard: 'Gemini', + windsurf: 'Windsurf', + codeium: 'Windsurf', + replit: 'Replit', + linear: 'Linear', + 'figma ai': 'Figma AI', + figma: 'Figma AI', + v0: 'v0', + 'vercel v0': 'v0', }; export function normalizeTool(tool: string): SupportedTool | null { diff --git a/packages/react/src/LearningPath.tsx b/packages/react/src/LearningPath.tsx index 98f401c..a051bdb 100644 --- a/packages/react/src/LearningPath.tsx +++ b/packages/react/src/LearningPath.tsx @@ -15,7 +15,7 @@ export interface LearningPathProps { style?: CSSProperties; } -const THEMES: Record = { +export const THEMES: Record = { warm: { ['--lk-surface' as string]: '#FFFFFF', ['--lk-ink' as string]: '#1A2547', diff --git a/packages/react/src/ProgressTracker.tsx b/packages/react/src/ProgressTracker.tsx new file mode 100644 index 0000000..fbcb891 --- /dev/null +++ b/packages/react/src/ProgressTracker.tsx @@ -0,0 +1,175 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import type { CSSProperties } from 'react'; +import { generateLearningPath } from '@learnkit-ai/core'; +import type { LearningPathInput, Lesson } from '@learnkit-ai/schemas'; +import { LessonCard } from './LessonCard'; +import type { LessonStatus } from './LessonCard'; +import type { LearnKitTheme } from './LearningPath'; +import { THEMES } from './LearningPath'; + +export interface ProgressTrackerProps { + input: LearningPathInput; + theme?: LearnKitTheme; + onLessonClick?: (lesson: Lesson) => void; + className?: string; + style?: CSSProperties; +} + +function readStorage(key: string): Set { + if (typeof window === 'undefined') return new Set(); + try { + const raw = window.localStorage.getItem(key); + const parsed: unknown = raw ? JSON.parse(raw) : []; + return new Set(Array.isArray(parsed) ? (parsed as string[]) : []); + } catch { + return new Set(); + } +} + +function writeStorage(key: string, ids: Set): void { + try { + window.localStorage.setItem(key, JSON.stringify([...ids])); + } catch { + // localStorage unavailable — progress not persisted + } +} + +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'; +} + +export function ProgressTracker({ + input, + theme = 'warm', + onLessonClick, + className, + style, +}: ProgressTrackerProps) { + let path; + let error: Error | null = null; + try { + path = generateLearningPath(input); + } catch (e) { + error = e instanceof Error ? e : new Error(String(e)); + } + + const storageKey = path ? `lk-progress-${path.id}` : ''; + + const [completedIds, setCompletedIds] = useState>(() => + storageKey ? readStorage(storageKey) : new Set(), + ); + + const toggle = useCallback( + (lesson: Lesson) => { + setCompletedIds((prev) => { + const next = new Set(prev); + if (next.has(lesson.id)) { + next.delete(lesson.id); + } else { + next.add(lesson.id); + } + writeStorage(storageKey, next); + return next; + }); + onLessonClick?.(lesson); + }, + [storageKey, onLessonClick], + ); + + if (error || !path) { + return ( +
+ {error?.message ?? 'Unable to generate learning path.'} +
+ ); + } + + const allIds = path.weeks.flatMap((w) => w.lessons.map((l) => l.id)); + const completedCount = allIds.filter((id) => completedIds.has(id)).length; + + return ( +
+
+ + {path.weeks.length} weeks · {allIds.length} lessons ·{' '} + {Math.round(path.totalMinutes / 60)}h + + + {completedCount}/{allIds.length} done + +
+ + {path.weeks.map((week) => ( +
+

+ + Week {week.index} + + {week.title} +

+
+ {week.lessons.map((lesson) => ( + + ))} +
+
+ ))} +
+ ); +} diff --git a/packages/react/src/__tests__/ProgressTracker.test.tsx b/packages/react/src/__tests__/ProgressTracker.test.tsx new file mode 100644 index 0000000..47b9e06 --- /dev/null +++ b/packages/react/src/__tests__/ProgressTracker.test.tsx @@ -0,0 +1,67 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, expect, it, beforeEach } from 'vitest'; +import type { LearningPathInput } from '@learnkit-ai/schemas'; +import { ProgressTracker } from '../ProgressTracker'; + +const INPUT: LearningPathInput = { + role: 'Software Engineer', + tools: ['Claude'], + goal: 'Ship an AI code review tool', + level: 'beginner', +}; + +beforeEach(() => { + localStorage.clear(); +}); + +describe('ProgressTracker', () => { + it('renders 4 week headings', () => { + render(); + expect(screen.getByText(/Week 1/)).toBeInTheDocument(); + expect(screen.getByText(/Week 4/)).toBeInTheDocument(); + }); + + it('renders a completion count', () => { + render(); + expect(screen.getByText(/0\/12 done/i)).toBeInTheDocument(); + }); + + it('marks a lesson completed when clicked', () => { + render(); + const buttons = screen.getAllByRole('button'); + // First button should be 'in-progress' (clickable) + fireEvent.click(buttons[0]!); + expect(screen.getByText(/1\/12 done/i)).toBeInTheDocument(); + }); + + it('toggles completion off when a completed lesson is clicked', () => { + render(); + const buttons = screen.getAllByRole('button'); + fireEvent.click(buttons[0]!); + expect(screen.getByText(/1\/12 done/i)).toBeInTheDocument(); + fireEvent.click(buttons[0]!); + expect(screen.getByText(/0\/12 done/i)).toBeInTheDocument(); + }); + + it('calls onLessonClick when a lesson is clicked', () => { + let clicked = false; + render( { clicked = true; }} />); + const buttons = screen.getAllByRole('button'); + fireEvent.click(buttons[0]!); + expect(clicked).toBe(true); + }); + + it('renders an error for invalid input', () => { + const bad = { role: '', tools: [], goal: '', level: 'beginner' } as unknown as LearningPathInput; + render(); + 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; + for (const theme of themes) { + const { unmount } = render(); + unmount(); + } + }); +}); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 0ce940c..44e9997 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -5,7 +5,8 @@ * No Tailwind dependency. CSS custom properties only. Drop into any host theme. */ -export { LearningPath, type LearningPathProps, type LearnKitTheme } from './LearningPath'; +export { LearningPath, type LearningPathProps, type LearnKitTheme, THEMES } from './LearningPath'; export { LessonCard, type LessonCardProps, type LessonStatus } from './LessonCard'; export { AIGuide, type AIGuideProps } from './AIGuide'; export { useLearnKit, type UseLearnKitResult } from './useLearnKit'; +export { ProgressTracker, type ProgressTrackerProps } from './ProgressTracker'; diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts index 45626f3..42d416c 100644 --- a/packages/schemas/src/index.ts +++ b/packages/schemas/src/index.ts @@ -46,3 +46,33 @@ export const LearningPathSchema = z.object({ generatedAt: z.string().datetime(), }); export type LearningPath = z.infer; + +export const ExerciseSchema = z.object({ + prompt: z.string(), + expectedOutput: z.string(), + rubricHint: z.string(), +}); +export type Exercise = z.infer; + +export const RubricItemSchema = z.object({ + criterion: z.string(), + excellent: z.string(), + acceptable: z.string(), + needsWork: z.string(), +}); +export type RubricItem = z.infer; + +export const LessonContentSchema = z.object({ + lessonId: z.string(), + body: z.string(), + exercises: z.array(ExerciseSchema).min(1).max(5), + rubric: z.array(RubricItemSchema).min(1).max(5), +}); +export type LessonContent = z.infer; + +export const LearningPathProgressSchema = z.object({ + pathId: z.string(), + completedLessonIds: z.array(z.string()), + updatedAt: z.string().datetime(), +}); +export type LearningPathProgress = z.infer;