diff --git a/.gitignore b/.gitignore index 4e69aa4..1436f73 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ dist-ssr .claude.local.md .claude-*/ CLAUDE.md +.superpowers/ diff --git a/docs/superpowers/plans/2026-04-26-lessons-system.md b/docs/superpowers/plans/2026-04-26-lessons-system.md new file mode 100644 index 0000000..376a1de --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-lessons-system.md @@ -0,0 +1,916 @@ +# Lessons & Challenges System — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add structured learning (guided lessons + free-form challenges) to WeCode, reusing the existing IDE shell via a React context provider. + +**Architecture:** The Home rail becomes a router (`useHomeStore.tab`). A new `view: "lesson"` wraps the IDE in a `LessonProvider` context that overrides file operations, shows a lesson dock, and runs a validation engine against the user's code. Progression persists in `wecode.progress.json` via `tauri-plugin-store`. + +**Tech Stack:** React 19, Zustand, TypeScript, Vitest, DOMParser (native), tauri-plugin-store. + +**Spec:** `docs/superpowers/specs/2026-04-26-lessons-system-design.md` + +--- + +### Task 1: Validation types + +**Files:** + +- Create: `src/lessons/validation/types.ts` +- Test: `src/lessons/validation/types.test.ts` + +- [ ] **Step 1: Write the validation rule type union** + +```ts +// src/lessons/validation/types.ts +export type MatchMode = + | "exact" + | "contains" + | "starts-with" + | "ends-with" + | "regex" + | "not-empty" + | "exists" + | "gte" + | "lte"; + +export type ValidationRule = + | { type: "element-exists"; selector: string; file: string } + | { type: "element-count"; selector: string; file: string; min?: number; max?: number } + | { type: "element-text"; selector: string; file: string; text: string; match: MatchMode } + | { type: "attribute-exists"; selector: string; file: string; attribute: string } + | { + type: "attribute-value"; + selector: string; + file: string; + attribute: string; + value?: string; + match: MatchMode; + } + | { type: "attribute-count"; selector: string; file: string; minAttributes: number } + | { type: "css-property"; selector: string; file: string; property: string; match: MatchMode } + | { + type: "css-property-value"; + selector: string; + file: string; + property: string; + value: string; + match: MatchMode; + } + | { type: "file-contains"; file: string; text: string } + | { type: "file-not-contains"; file: string; text: string } + | { type: "file-regex"; file: string; pattern: string } + | { type: "nesting"; parent: string; child: string; direct?: boolean; file: string } + | { type: "element-order"; selectors: string[]; within: string; file: string } + | { type: "sibling"; first: string; then: string; file: string } + | { type: "indent-style"; file: string; style: "spaces" | "tabs"; size?: number } + | { type: "composite"; operator: "and" | "or"; rules: ValidationRule[] }; + +export interface CheckpointResult { + checkpointId: string; + passed: boolean; +} +``` + +- [ ] **Step 2: Write a smoke test to verify the types compile** + +```ts +// src/lessons/validation/types.test.ts +import { describe, expect, test } from "vitest"; +import type { ValidationRule } from "./types"; + +describe("ValidationRule types", () => { + test("element-exists rule is well-typed", () => { + const rule: ValidationRule = { type: "element-exists", selector: "h1", file: "/index.html" }; + expect(rule.type).toBe("element-exists"); + }); + + test("composite rule accepts nested rules", () => { + const rule: ValidationRule = { + type: "composite", + operator: "and", + rules: [ + { type: "element-exists", selector: "h1", file: "/index.html" }, + { type: "file-contains", file: "/index.html", text: "

" }, + ], + }; + expect(rule.rules).toHaveLength(2); + }); +}); +``` + +- [ ] **Step 3: Run tests** + +Run: `npm test -- --run src/lessons/validation/types.test.ts` +Expected: 2 tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/lessons/validation/types.ts src/lessons/validation/types.test.ts +git commit -m "feat(lessons): add validation rule type definitions" +``` + +--- + +### Task 2: Core validation rules (element-exists, file-contains, css-property) + +**Files:** + +- Create: `src/lessons/validation/rules/elementExists.ts` +- Create: `src/lessons/validation/rules/fileContains.ts` +- Create: `src/lessons/validation/rules/cssProperty.ts` +- Create: `src/lessons/validation/rules/composite.ts` +- Create: `src/lessons/validation/rules/nesting.ts` +- Test: `src/lessons/validation/rules/rules.test.ts` + +For each rule function: signature is `(rule: SpecificRule, files: Record) => boolean`. `files` is the VFS snapshot (`{ "/index.html": "...", "/style.css": "body{...}" }`). Pure functions, no DOM globals — use `new DOMParser()` (available in jsdom/browser). + +- [ ] **Step 1: Write failing tests for core rules** + +```ts +// src/lessons/validation/rules/rules.test.ts +// @vitest-environment jsdom +import { describe, expect, test } from "vitest"; + +import { checkElementExists } from "./elementExists"; +import { checkFileContains, checkFileNotContains } from "./fileContains"; +import { checkCssProperty } from "./cssProperty"; +import { checkNesting } from "./nesting"; +import { checkComposite } from "./composite"; + +const HTML = `Test

Hello

World

`; +const CSS = `body { background-color: red; font-size: 16px; }\n.title { color: blue; }`; +const FILES: Record = { "/index.html": HTML, "/style.css": CSS }; + +describe("elementExists", () => { + test("passes when selector matches", () => { + expect( + checkElementExists({ type: "element-exists", selector: "h1", file: "/index.html" }, FILES), + ).toBe(true); + }); + test("fails when selector does not match", () => { + expect( + checkElementExists({ type: "element-exists", selector: "h2", file: "/index.html" }, FILES), + ).toBe(false); + }); + test("fails when file is missing", () => { + expect( + checkElementExists({ type: "element-exists", selector: "h1", file: "/missing.html" }, FILES), + ).toBe(false); + }); +}); + +describe("fileContains", () => { + test("passes when text is present", () => { + expect( + checkFileContains( + { type: "file-contains", file: "/style.css", text: "background-color" }, + FILES, + ), + ).toBe(true); + }); + test("fails when text is absent", () => { + expect( + checkFileContains( + { type: "file-contains", file: "/style.css", text: "display: flex" }, + FILES, + ), + ).toBe(false); + }); +}); + +describe("fileNotContains", () => { + test("passes when text is absent", () => { + expect( + checkFileNotContains( + { type: "file-not-contains", file: "/index.html", text: "

" }, + FILES, + ), + ).toBe(true); + }); + test("fails when text is present", () => { + expect( + checkFileNotContains({ type: "file-not-contains", file: "/index.html", text: " { + test("passes when selector has the property", () => { + expect( + checkCssProperty( + { + type: "css-property", + selector: "body", + file: "/style.css", + property: "background-color", + match: "exists", + }, + FILES, + ), + ).toBe(true); + }); + test("fails when property is missing", () => { + expect( + checkCssProperty( + { + type: "css-property", + selector: "body", + file: "/style.css", + property: "display", + match: "exists", + }, + FILES, + ), + ).toBe(false); + }); + test("fails when selector is missing", () => { + expect( + checkCssProperty( + { + type: "css-property", + selector: ".missing", + file: "/style.css", + property: "color", + match: "exists", + }, + FILES, + ), + ).toBe(false); + }); +}); + +describe("nesting", () => { + test("passes when child is inside parent", () => { + expect( + checkNesting({ type: "nesting", parent: "body", child: "h1", file: "/index.html" }, FILES), + ).toBe(true); + }); + test("fails when child is not inside parent", () => { + expect( + checkNesting({ type: "nesting", parent: "h1", child: "p", file: "/index.html" }, FILES), + ).toBe(false); + }); + test("direct child check", () => { + expect( + checkNesting( + { type: "nesting", parent: "body", child: "h1", direct: true, file: "/index.html" }, + FILES, + ), + ).toBe(true); + expect( + checkNesting( + { type: "nesting", parent: "html", child: "h1", direct: true, file: "/index.html" }, + FILES, + ), + ).toBe(false); + }); +}); + +describe("composite", () => { + test("AND passes when all sub-rules pass", () => { + expect( + checkComposite( + { + type: "composite", + operator: "and", + rules: [ + { type: "element-exists", selector: "h1", file: "/index.html" }, + { type: "file-contains", file: "/style.css", text: "body" }, + ], + }, + FILES, + ), + ).toBe(true); + }); + test("AND fails when one sub-rule fails", () => { + expect( + checkComposite( + { + type: "composite", + operator: "and", + rules: [ + { type: "element-exists", selector: "h1", file: "/index.html" }, + { type: "element-exists", selector: "h99", file: "/index.html" }, + ], + }, + FILES, + ), + ).toBe(false); + }); + test("OR passes when any sub-rule passes", () => { + expect( + checkComposite( + { + type: "composite", + operator: "or", + rules: [ + { type: "element-exists", selector: "h99", file: "/index.html" }, + { type: "element-exists", selector: "h1", file: "/index.html" }, + ], + }, + FILES, + ), + ).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test -- --run src/lessons/validation/rules/rules.test.ts` +Expected: FAIL (modules not found). + +- [ ] **Step 3: Implement the rule functions** + +Each rule function follows: parse HTML/CSS from files map, apply the check, return boolean. + +`elementExists.ts`: + +```ts +export function checkElementExists( + rule: { type: "element-exists"; selector: string; file: string }, + files: Record, +): boolean { + const html = files[rule.file]; + if (html === undefined) return false; + const doc = new DOMParser().parseFromString(html, "text/html"); + return doc.querySelector(rule.selector) !== null; +} +``` + +`fileContains.ts`: + +```ts +export function checkFileContains( + rule: { type: "file-contains"; file: string; text: string }, + files: Record, +): boolean { + const content = files[rule.file]; + if (content === undefined) return false; + return content.includes(rule.text); +} + +export function checkFileNotContains( + rule: { type: "file-not-contains"; file: string; text: string }, + files: Record, +): boolean { + const content = files[rule.file]; + if (content === undefined) return true; + return !content.includes(rule.text); +} +``` + +`cssProperty.ts` — parses CSS text to find selector + property: + +```ts +const RULE_RE = /([^{}]+)\{([^}]*)\}/g; + +function findCssRules(css: string): Array<{ selector: string; body: string }> { + const results: Array<{ selector: string; body: string }> = []; + let match; + while ((match = RULE_RE.exec(css)) !== null) { + const selector = (match[1] ?? "").trim(); + const body = match[2] ?? ""; + results.push({ selector, body }); + } + return results; +} + +export function checkCssProperty( + rule: { type: "css-property"; selector: string; file: string; property: string; match: string }, + files: Record, +): boolean { + const css = files[rule.file]; + if (css === undefined) return false; + const parsed = findCssRules(css); + const matching = parsed.find((r) => r.selector === rule.selector); + if (!matching) return false; + const propRe = new RegExp(`${rule.property}\\s*:`, "i"); + return propRe.test(matching.body); +} +``` + +`nesting.ts`: + +```ts +export function checkNesting( + rule: { type: "nesting"; parent: string; child: string; direct?: boolean; file: string }, + files: Record, +): boolean { + const html = files[rule.file]; + if (html === undefined) return false; + const doc = new DOMParser().parseFromString(html, "text/html"); + const selector = rule.direct ? `${rule.parent} > ${rule.child}` : `${rule.parent} ${rule.child}`; + return doc.querySelector(selector) !== null; +} +``` + +`composite.ts` — imports all other rules and delegates: + +```ts +import type { ValidationRule } from "../types"; +import { checkElementExists } from "./elementExists"; +import { checkFileContains, checkFileNotContains } from "./fileContains"; +import { checkCssProperty } from "./cssProperty"; +import { checkNesting } from "./nesting"; + +export function evaluateRule(rule: ValidationRule, files: Record): boolean { + switch (rule.type) { + case "element-exists": + return checkElementExists(rule, files); + case "file-contains": + return checkFileContains(rule, files); + case "file-not-contains": + return checkFileNotContains(rule, files); + case "css-property": + return checkCssProperty(rule, files); + case "nesting": + return checkNesting(rule, files); + case "composite": + return checkComposite(rule, files); + default: + return false; + } +} + +export function checkComposite( + rule: { type: "composite"; operator: "and" | "or"; rules: ValidationRule[] }, + files: Record, +): boolean { + if (rule.operator === "and") return rule.rules.every((r) => evaluateRule(r, files)); + return rule.rules.some((r) => evaluateRule(r, files)); +} +``` + +- [ ] **Step 4: Run tests** + +Run: `npm test -- --run src/lessons/validation/rules/rules.test.ts` +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/lessons/validation/rules/ +git commit -m "feat(validation): implement core rule checkers with tests" +``` + +--- + +### Task 3: Validation orchestrator + +**Files:** + +- Create: `src/lessons/validation/validate.ts` +- Test: `src/lessons/validation/validate.test.ts` + +- [ ] **Step 1: Write failing test** + +```ts +// src/lessons/validation/validate.test.ts +// @vitest-environment jsdom +import { describe, expect, test } from "vitest"; +import { validateCheckpoints } from "./validate"; +import type { CheckpointResult } from "./types"; + +const FILES = { + "/index.html": "

Hi

", + "/style.css": "body { color: red; }", +}; + +const CHECKPOINTS = [ + { + id: "has-head", + label: "Add head", + rule: { type: "element-exists" as const, selector: "head", file: "/index.html" }, + }, + { + id: "has-h2", + label: "Add h2", + rule: { type: "element-exists" as const, selector: "h2", file: "/index.html" }, + }, + { + id: "has-color", + label: "Set color", + rule: { + type: "css-property" as const, + selector: "body", + file: "/style.css", + property: "color", + match: "exists" as const, + }, + }, +]; + +describe("validateCheckpoints", () => { + test("returns results for each checkpoint", () => { + const results = validateCheckpoints(CHECKPOINTS, FILES); + expect(results).toHaveLength(3); + expect(results.find((r) => r.checkpointId === "has-head")?.passed).toBe(true); + expect(results.find((r) => r.checkpointId === "has-h2")?.passed).toBe(false); + expect(results.find((r) => r.checkpointId === "has-color")?.passed).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Implement** + +```ts +// src/lessons/validation/validate.ts +import type { CheckpointResult, ValidationRule } from "./types"; +import { evaluateRule } from "./rules/composite"; + +interface CheckpointDef { + id: string; + label: string; + rule: ValidationRule; +} + +export function validateCheckpoints( + checkpoints: CheckpointDef[], + files: Record, +): CheckpointResult[] { + return checkpoints.map((cp) => ({ + checkpointId: cp.id, + passed: evaluateRule(cp.rule, files), + })); +} +``` + +- [ ] **Step 3: Run tests, commit** + +Run: `npm test -- --run src/lessons/validation/validate.test.ts` + +```bash +git add src/lessons/validation/validate.ts src/lessons/validation/validate.test.ts +git commit -m "feat(validation): add checkpoint validation orchestrator" +``` + +--- + +### Task 4: Lesson data types, JSON content, and registry + +**Files:** + +- Create: `src/lessons/types.ts` +- Create: `src/lessons/data/html-01-structure.json` +- Create: `src/lessons/data/challenge-01-simple-page.json` +- Create: `src/lessons/data/index.ts` + +- [ ] **Step 1: Write the lesson data types** + +```ts +// src/lessons/types.ts +import type { ValidationRule } from "./validation/types"; + +export interface LessonParagraph { + kind: "text" | "code"; + content: string; +} + +export interface CheckpointDef { + id: string; + label: string; + rule: ValidationRule; +} + +export interface LessonStep { + heading: string; + paragraphs: LessonParagraph[]; + checkpoints: CheckpointDef[]; +} + +export interface LessonData { + id: string; + type: "lesson" | "challenge"; + title: string; + description: string; + difficulty: "débutant" | "intermédiaire" | "avancé"; + estimatedMinutes: number; + tags: string[]; + allowFileOps: boolean; + starterFiles: Record; + steps: LessonStep[]; +} + +export type CheckpointStatus = "todo" | "active" | "done"; + +export interface CheckpointState { + id: string; + status: CheckpointStatus; +} +``` + +- [ ] **Step 2: Write the first lesson JSON** + +Create `src/lessons/data/html-01-structure.json` with a lesson teaching ``, ``, ``, `<h1>`, `<p>`. One step, 5 checkpoints using `element-exists` and `element-text` rules. Starter file is a bare `<!DOCTYPE html>\n<html>\n</html>`. + +- [ ] **Step 3: Write the first challenge JSON** + +Create `src/lessons/data/challenge-01-simple-page.json` with `allowFileOps: true`, starter files `index.html` + `style.css`, and checkpoints for h1, p, background-color. + +- [ ] **Step 4: Write the registry** + +```ts +// src/lessons/data/index.ts +import type { LessonData } from "../types"; +import html01 from "./html-01-structure.json"; +import challenge01 from "./challenge-01-simple-page.json"; + +export const LESSONS: readonly LessonData[] = [html01 as LessonData]; +export const CHALLENGES: readonly LessonData[] = [challenge01 as LessonData]; +export const ALL_CONTENT: readonly LessonData[] = [...LESSONS, ...CHALLENGES]; + +export function getLessonById(id: string): LessonData | undefined { + return ALL_CONTENT.find((l) => l.id === id); +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/lessons/types.ts src/lessons/data/ +git commit -m "feat(lessons): add data types, first lesson and challenge content" +``` + +--- + +### Task 5: Lesson store + progress persistence + +**Files:** + +- Create: `src/lessons/lessonStore.ts` +- Create: `src/lessons/progressPersistence.ts` +- Test: `src/lessons/lessonStore.test.ts` + +- [ ] **Step 1: Write the lesson store** + +Zustand store with `activeLessonId`, `checkpointStates`, `startLesson(id)`, `completeCheckpoint(id)`, `resetLesson(id)`, `exitLesson()`. `startLesson` hydrates VFS with starter files and sets `useIdeStore.view` to `"lesson"`. + +- [ ] **Step 2: Write progress persistence** + +`loadProgress()` and `saveProgress()` using `persistedStore("wecode.progress.json")`, same pattern as `src/projects/persistence.ts`. + +- [ ] **Step 3: Write tests for the store** + +Test `startLesson` sets `activeLessonId`, `completeCheckpoint` transitions status, `exitLesson` clears state and sets view to `"home"`. + +- [ ] **Step 4: Run tests, commit** + +```bash +git add src/lessons/lessonStore.ts src/lessons/progressPersistence.ts src/lessons/lessonStore.test.ts +git commit -m "feat(lessons): add lesson store and progress persistence" +``` + +--- + +### Task 6: Home store + rail routing + +**Files:** + +- Create: `src/home/homeStore.ts` +- Modify: `src/home/rail/HomeNav.tsx` +- Modify: `src/home/HomeShell.tsx` + +- [ ] **Step 1: Create home store** + +```ts +// src/home/homeStore.ts +import { create } from "zustand"; + +export type HomeTab = "accueil" | "lessons" | "challenges"; + +interface HomeState { + tab: HomeTab; + setTab: (tab: HomeTab) => void; +} + +export const useHomeStore = create<HomeState>((set) => ({ + tab: "accueil", + setTab: (tab) => set({ tab }), +})); +``` + +- [ ] **Step 2: Update HomeNav with routable items + counters** + +Add Leçons item (with `X / Y` counter from progress store) and Challenges item. Accueil is the default active. Items like Projets, Cheatsheets, Settings stay disabled. + +- [ ] **Step 3: Update HomeShell to route based on tab** + +```tsx +// HomeShell main zone switches on useHomeStore.tab: +// "accueil" → current content (AccueilView extracted from current HomeShell) +// "lessons" → <LessonsListView /> +// "challenges" → <ChallengesListView /> +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/home/homeStore.ts src/home/rail/HomeNav.tsx src/home/HomeShell.tsx +git commit -m "feat(home): add rail tab routing for lessons and challenges" +``` + +--- + +### Task 7: Lessons & Challenges list views + +**Files:** + +- Create: `src/home/sections/LessonCard.tsx` +- Create: `src/home/sections/LessonsListView.tsx` +- Create: `src/home/sections/ChallengesListView.tsx` + +- [ ] **Step 1: Create LessonCard component** + +Displays: status badge (terminé/en cours/pas commencé), estimated time, title, description, optional progress bar. Clicking calls `useLessonStore.startLesson(id)`. + +- [ ] **Step 2: Create LessonsListView** + +Imports `LESSONS` from registry, reads progress from `useLessonStore`, renders a grid of `LessonCard` components with the heading "Toutes les leçons" and a completion counter. + +- [ ] **Step 3: Create ChallengesListView** + +Same structure but for challenges. Status is `✓ réussi` or `pas tenté`. No progress bar. + +- [ ] **Step 4: Add CSS for lesson cards and list views** + +New `.lesson-card`, `.lesson-card--in-progress`, `.lesson-card--done` classes in `global.css`. Follow the existing card pattern (`.home-proj` style) with status badges using `--ok` for done and `--accent` for in-progress. + +- [ ] **Step 5: Commit** + +```bash +git add src/home/sections/LessonCard.tsx src/home/sections/LessonsListView.tsx src/home/sections/ChallengesListView.tsx src/styles/global.css +git commit -m "feat(home): add lessons and challenges list views with cards" +``` + +--- + +### Task 8: LessonProvider context + +**Files:** + +- Create: `src/lessons/LessonProvider.tsx` +- Create: `src/lessons/useLessonContext.ts` + +- [ ] **Step 1: Create the context and provider** + +`LessonProvider` wraps children, reads `activeLessonId` from `useLessonStore`, loads the lesson data via `getLessonById`, subscribes to VFS changes (debounced 300 ms) to run `validateCheckpoints`, and exposes the `LessonContextValue` interface from the spec. + +- [ ] **Step 2: Create the consumer hook** + +```ts +// src/lessons/useLessonContext.ts +import { useContext } from "react"; +import { LessonContext } from "./LessonProvider"; + +export function useLessonContext() { + return useContext(LessonContext); +} +``` + +Returns `LessonContextValue | null`. Components check for null to know if they're in lesson mode. + +- [ ] **Step 3: Commit** + +```bash +git add src/lessons/LessonProvider.tsx src/lessons/useLessonContext.ts +git commit -m "feat(lessons): add LessonProvider context with validation wiring" +``` + +--- + +### Task 9: Dock UI rewrite + +**Files:** + +- Modify: `src/ide/dock/LessonDock.tsx` +- Modify: `src/styles/global.css` + +- [ ] **Step 1: Rewrite LessonDock to read context** + +Replace mock data imports with `useLessonContext()`. If context is null, return null (hides dock in project mode). Render the design-faithful layout: `.dock-head` with chevron + lesson-chip + progress, `.dock-body` grid with `.lesson-text` (left) and `.checkpoints` (right). Checkpoint rows use `.cp`, `.cp.active`, `.cp.done` classes based on `checkpoints[i].status`. + +- [ ] **Step 2: Update CSS to match the Claude Design handoff** + +Port the CSS from `WeCode IDE.html:504–610` into `global.css`. Key classes: `.dock`, `.dock-head`, `.lesson-chip`, `.progress`, `.progress-ring`, `.dock-body`, `.lesson-text`, `.checkpoints`, `.cp`, `.cp.done`, `.cp.active`. All using existing design tokens. + +- [ ] **Step 3: Remove mockLesson.ts** + +Delete `src/ide/dock/mockLesson.ts` — no longer needed. + +- [ ] **Step 4: Commit** + +```bash +git add src/ide/dock/LessonDock.tsx src/ide/dock/mockLesson.ts src/styles/global.css +git commit -m "feat(dock): rewrite lesson dock to use context and match design" +``` + +--- + +### Task 10: IDE shell adaptations + +**Files:** + +- Modify: `src/ide/tree/FileTree.tsx` +- Modify: `src/ide/shell/Toolbar.tsx` +- Modify: `src/ide/shell/StatusBar.tsx` +- Modify: `src/App.tsx` +- Modify: `src/state/ideStore.ts` + +- [ ] **Step 1: Add "lesson" to the View type** + +In `src/state/ideStore.ts`, extend `View = "home" | "ide"` to `View = "home" | "ide" | "lesson"`. + +- [ ] **Step 2: FileTree — hide file-ops buttons in lesson mode** + +Read `useLessonContext()`. If `context?.fileOpsLocked`, hide the "new file" button and suppress the create/rename/delete context menu items. + +- [ ] **Step 3: Toolbar — brand click exits lesson** + +Read `useLessonContext()`. If context exists, brand click calls `context.exitLesson()` instead of `setView("home")`. + +- [ ] **Step 4: StatusBar — show checkpoint progress** + +Read `useLessonContext()`. If context exists, replace the version chip with a checkpoint counter (`2 / 4 checkpoints`). + +- [ ] **Step 5: App.tsx — render LessonProvider when view is "lesson"** + +```tsx +{ + view === "lesson" ? ( + <LessonProvider> + <IdeShell key="lesson" /> + </LessonProvider> + ) : view === "home" ? ( + <HomeShell key="home" /> + ) : ( + <IdeShell key="ide" /> + ); +} +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/state/ideStore.ts src/ide/tree/FileTree.tsx src/ide/shell/Toolbar.tsx src/ide/shell/StatusBar.tsx src/App.tsx +git commit -m "feat(ide): adapt shell components for lesson mode via context" +``` + +--- + +### Task 11: Continue card adaptation + +**Files:** + +- Modify: `src/home/sections/ContinueSection.tsx` + +- [ ] **Step 1: Read lesson progress alongside project data** + +If an in-progress lesson exists (at least one checkpoint done, not all done), show it in the continue card instead of the last project. Priority: lesson in progress > last project. + +- [ ] **Step 2: Adapt continue card display for lessons** + +Show lesson title, step name, checkpoint progress (`2 / 4 checkpoints`), "Continuer" button calls `startLesson(id)`. + +- [ ] **Step 3: Commit** + +```bash +git add src/home/sections/ContinueSection.tsx +git commit -m "feat(home): continue card shows in-progress lesson" +``` + +--- + +### Task 12: Integration verification + +- [ ] **Step 1: Run full CI** + +```bash +npm run typecheck +npm run lint +npm run format:check +npm test +npm run build +cargo check --manifest-path src-tauri/Cargo.toml +cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings +``` + +- [ ] **Step 2: Manual smoke test** + +Following the spec verification checklist: + +1. Home → rail → click Leçons → grid appears +2. Click lesson card → IDE opens with starter files, dock shows instructions +3. Edit code to pass checkpoints → validation runs, checkpoints animate to "done" +4. Complete all → lesson marked complete +5. Brand click → back to Home, lesson shows ✓ +6. Challenges → click challenge → IDE with file ops enabled +7. Quit mid-lesson → reopen → progress restored + +- [ ] **Step 3: Final commit + push** + +```bash +git push +``` diff --git a/docs/superpowers/specs/2026-04-26-lessons-system-design.md b/docs/superpowers/specs/2026-04-26-lessons-system-design.md new file mode 100644 index 0000000..9e00175 --- /dev/null +++ b/docs/superpowers/specs/2026-04-26-lessons-system-design.md @@ -0,0 +1,306 @@ +# Lessons & Challenges System — Design Spec + +## Context + +WeCode is a desktop IDE that teaches HTML and CSS. The app currently has two views: Home (project picker) and IDE (editor + preview). This spec adds a third pillar: **structured learning** through guided lessons and free-form challenges, both reusing the existing IDE shell. + +The system ships as milestone 3. Scope for v1: the full engine (navigation, validation, progression, dock UI) plus **1 lesson + 1 challenge** as proof of concept. + +## Architecture + +### View routing + +``` +useIdeStore.view: "home" | "ide" | "lesson" +``` + +- `"home"` — Home shell with rail-routed sub-views +- `"ide"` — Free project editing (unchanged) +- `"lesson"` — IDE shell wrapped in a `LessonProvider` context + +### Home sub-views (rail as router) + +The Home rail gains clickable items that swap the main content zone: + +``` +useHomeStore.tab: "accueil" | "lessons" | "challenges" +``` + +| Rail item | Tab value | What the main zone shows | +| ---------- | -------------- | ------------------------------------------------------------------------ | +| Accueil | `"accueil"` | Current home content (search, continue card, recent projects, templates) | +| Leçons | `"lessons"` | Grid of all lessons with progress indicators | +| Challenges | `"challenges"` | Grid of all challenges with completion status | + +Items not yet wired (Projets, Cheatsheets, Settings) remain visible but disabled. + +The rail shows a progress counter next to Leçons: `3 / 12` (completed / total). + +### LessonProvider (React Context) + +When `view === "lesson"`, `App.tsx` renders: + +```tsx +<LessonProvider lessonId={activeLessonId}> + <IdeShell /> +</LessonProvider> +``` + +The context exposes: + +```ts +interface LessonContextValue { + lesson: LessonData; + checkpoints: CheckpointState[]; // { id, status: "todo"|"active"|"done" } + currentStepIndex: number; + fileOpsLocked: boolean; // true for lessons, false for challenges + progress: number; // 0–1 ratio + validateNow: () => void; + exitLesson: () => void; +} +``` + +Components that adapt their behavior in lesson mode: + +- **FileTree** — hides create/delete/rename buttons when `fileOpsLocked` +- **Toolbar** — brand click calls `exitLesson()` instead of `setView("home")` +- **LessonDock** — reads `lesson`, `checkpoints`, `currentStepIndex` from context +- **StatusBar** — shows checkpoint progress instead of version chip + +When the context is absent (free project mode), all components behave as today. + +### Stores + +**`useLessonStore`** (new Zustand store): + +- `activeLessonId: string | null` +- `checkpointStates: Record<string, CheckpointStatus>` +- `startLesson(id): void` — hydrates VFS with starter files, sets view to `"lesson"` +- `completeCheckpoint(id): void` +- `resetLesson(id): void` + +**`useHomeStore`** (new Zustand store): + +- `tab: "accueil" | "lessons" | "challenges"` +- `setTab(tab): void` + +**Global progression** — persisted via `tauri-plugin-store` as `wecode.progress.json`: + +```ts +interface ProgressStore { + version: 1; + completed: Record<string, { completedAt: number }>; // lessonId → timestamp + checkpoints: Record<string, string[]>; // lessonId → completed checkpoint ids +} +``` + +## Lesson data format + +Each lesson/challenge is a JSON file in `src/lessons/data/`: + +```ts +interface LessonData { + id: string; + type: "lesson" | "challenge"; + title: string; + description: string; + difficulty: "débutant" | "intermédiaire" | "avancé"; + estimatedMinutes: number; + tags: string[]; + allowFileOps: boolean; // false for lessons, true for challenges + starterFiles: Record<string, string>; // VFS path → content + steps: LessonStep[]; +} + +interface LessonStep { + heading: string; + paragraphs: LessonParagraph[]; + checkpoints: CheckpointDef[]; +} + +interface LessonParagraph { + kind: "text" | "code"; + content: string; // text may contain `backtick` for inline code +} + +interface CheckpointDef { + id: string; + label: string; + rule: ValidationRule; +} +``` + +## Validation engine + +### Rule types + +| Type | Fields | What it checks | +| -------------------- | -------------------------------------------------- | ------------------------------------------------------------------------ | +| `element-exists` | `selector`, `file` | `querySelector(selector)` returns ≥1 match | +| `element-count` | `selector`, `file`, `min?`, `max?` | Match count within range | +| `element-text` | `selector`, `file`, `text`, `match` | Element text content matches (`contains`, `exact`, `regex`, `not-empty`) | +| `attribute-exists` | `selector`, `file`, `attribute` | Element has the attribute | +| `attribute-value` | `selector`, `file`, `attribute`, `value?`, `match` | Attribute value check | +| `attribute-count` | `selector`, `file`, `minAttributes` | Element has ≥N attributes | +| `css-property` | `selector`, `file`, `property`, `match` | CSS property exists on selector | +| `css-property-value` | `selector`, `file`, `property`, `value`, `match` | CSS property value check (`exact`, `contains`, `gte`, `lte`) | +| `file-contains` | `file`, `text` | Raw source contains string | +| `file-not-contains` | `file`, `text` | Raw source does NOT contain string | +| `file-regex` | `file`, `pattern` | Raw source matches regex | +| `nesting` | `parent`, `child`, `direct?`, `file` | Child is descendant (or direct child) of parent | +| `element-order` | `selectors[]`, `within`, `file` | Elements appear in document order | +| `sibling` | `first`, `then`, `file` | Two elements are adjacent siblings | +| `indent-style` | `file`, `style` (`spaces`\|`tabs`), `size?` | Source follows indentation convention | +| `composite` | `operator` (`and`\|`or`), `rules[]` | Combines multiple rules | + +### Match modes + +`exact`, `contains`, `starts-with`, `ends-with`, `regex`, `not-empty`, `exists`, `gte`, `lte`. + +### Execution + +- **Trigger**: VFS `change` event, debounced 300 ms. +- **Method**: `DOMParser` parses the HTML source into a DOM. `querySelector` / `querySelectorAll` run the selectors. CSS rules are checked by parsing `<style>` tags and linked stylesheets from the VFS snapshot. +- **Output**: array of `{ checkpointId, passed: boolean }`. The `LessonProvider` diffs against current state and updates `useLessonStore`. +- **Feedback**: checkpoint rows in the dock animate from `active` (ambre, "vérification…") to `done` (vert, "fait") when a rule passes. + +### Module location + +`src/lessons/validation/` — pure functions, no React, testable under Node: + +- `validate.ts` — orchestrator +- `rules/` — one file per rule type + +## Dock UI + +Reproduces the Claude Design handoff exactly (CSS from `WeCode IDE.html:504–610`, markup from `:862–920`). + +### Layout + +``` +.dock +├── .dock-head (40px, clickable → toggle collapse) +│ ├── chevron SVG (rotates on collapse) +│ ├── .lesson-chip (ambre pill: number + "Leçon · titre") +│ └── .progress (right-aligned: "2 / 4 checkpoints" + progress ring SVG) +│ +└── .dock-body (grid: 1fr 1.2fr, scrollable) + ├── .lesson-text (left: heading + paragraphs + hint footer) + └── .checkpoints (right: list of .cp rows) +``` + +### Checkpoint states + +| Class | Tick | Label | Meta | +| ------------ | ---------------------------------------- | -------------------- | ----------------------- | +| `.cp` (todo) | Empty circle, `--line` border | Normal text | `—` | +| `.cp.active` | Circle, `--accent` border, ambre bg | Normal text | `vérification…` (ambre) | +| `.cp.done` | Filled `--accent`, checkmark in `--bg-0` | Strikethrough, muted | `fait` | + +### Visibility + +- **Lesson/challenge mode**: dock visible, collapsible +- **Free project mode**: dock hidden (not rendered) + +## Home views + +### Lessons list (`tab === "lessons"`) + +Grid of lesson cards. Each card shows: + +- Status badge: `✓ terminé` (green), `● en cours` (ambre), `pas commencé` (muted) +- Estimated time +- Title with lesson number +- Description (1 line) +- Progress bar (for in-progress lessons) + +Clicking a card calls `startLesson(id)` → hydrates VFS, switches to `view: "lesson"`. + +### Challenges list (`tab === "challenges"`) + +Same card layout, adapted for challenges: + +- Status: `✓ réussi` or `pas tenté` +- No progress bar (challenges are pass/fail) +- Clicking starts the challenge + +### Continue card adaptation + +The existing "Reprends là où tu t'étais arrêté" card on the Accueil tab gains the ability to show an in-progress lesson (not just projects). Priority: lesson in progress > last opened project. + +## File structure + +``` +src/lessons/ + data/ # JSON lesson files + html-01-structure.json + challenge-01-simple-page.json + index.ts # registry: exports LESSONS and CHALLENGES arrays + validation/ + validate.ts # orchestrator + rules/ # one file per rule type + elementExists.ts + cssProperty.ts + fileContains.ts + composite.ts + ... + types.ts # ValidationRule union type + LessonProvider.tsx # React context provider + lessonStore.ts # Zustand store + progressPersistence.ts # tauri-plugin-store read/write + +src/home/ + homeStore.ts # tab routing state + sections/ + LessonsListView.tsx # grid of lesson cards + ChallengesListView.tsx # grid of challenge cards + LessonCard.tsx # individual card + rail/ + HomeNav.tsx # gains Leçons + Challenges items with counters + +src/ide/ + dock/ + LessonDock.tsx # rewritten to read LessonContext + ProgressRing.tsx # unchanged + tree/ + FileTree.tsx # reads fileOpsLocked from context + shell/ + Toolbar.tsx # brand click → exitLesson in lesson mode + StatusBar.tsx # checkpoint count in lesson mode +``` + +## v1 content + +### Lesson: "La structure d'une page HTML" + +- 1 step, 4–5 checkpoints +- Starter file: bare `index.html` with `<!DOCTYPE html><html></html>` +- Teaches: `<head>`, `<body>`, `<title>`, `<h1>`, `<p>` +- Rules: `element-exists` for each tag, `element-text` for title content + +### Challenge: "Reproduis cette page simple" + +- Starter files: empty `index.html` + `style.css` +- Goal: create a page with a heading, a paragraph, and a colored background +- Rules: `element-exists` for h1, p; `css-property` for background-color on body; `nesting` for h1 inside body +- `allowFileOps: true` + +## Verification + +```bash +npm run typecheck +npm run lint +npm run test # validation engine unit tests + lesson store tests +npm run build +cargo check --manifest-path src-tauri/Cargo.toml +``` + +Manual: + +1. Home → rail → click Leçons → grid of lessons appears +2. Click lesson → IDE opens with starter files, dock shows instructions +3. Follow checkpoints → validation runs on each save, checkpoints animate to "done" +4. Complete all checkpoints → lesson marked as complete in progress store +5. Click brand → back to Home, lesson shows "✓ terminé" +6. Home → rail → Challenges → click challenge → IDE opens with file ops enabled +7. Quit mid-lesson → reopen → progress restored diff --git a/src/App.tsx b/src/App.tsx index 7d38814..05a950e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { LogoDefs } from "./components/LogoMark"; import { HomeShell } from "./home/HomeShell"; import { IdeShell } from "./ide/shell/IdeShell"; import { Toasts } from "./ide/shell/Toasts"; +import { LessonProvider } from "./lessons/LessonProvider"; import { CommandPalette } from "./palette/CommandPalette"; import { useCommandPaletteShortcut } from "./palette/useCommandPaletteShortcut"; import { useIdeStore } from "./state/ideStore"; @@ -13,7 +14,15 @@ function App() { return ( <> <LogoDefs /> - {view === "home" ? <HomeShell key="home" /> : <IdeShell key="ide" />} + {view === "lesson" ? ( + <LessonProvider> + <IdeShell key="lesson" /> + </LessonProvider> + ) : view === "home" ? ( + <HomeShell key="home" /> + ) : ( + <IdeShell key="ide" /> + )} <CommandPalette /> <Toasts /> </> diff --git a/src/home/HomeShell.tsx b/src/home/HomeShell.tsx index e5b0e63..b549b85 100644 --- a/src/home/HomeShell.tsx +++ b/src/home/HomeShell.tsx @@ -1,23 +1,34 @@ import { ProjectModalsHost } from "../projects/ui/ProjectModalsHost"; +import { useHomeStore } from "./homeStore"; import { HomeRail } from "./rail/HomeRail"; import { BottomTip } from "./sections/BottomTip"; +import { ChallengesListView } from "./sections/ChallengesListView"; import { ContinueSection } from "./sections/ContinueSection"; import { HomeSearch } from "./sections/HomeSearch"; import { LessonPathSection } from "./sections/LessonPathSection"; +import { LessonsListView } from "./sections/LessonsListView"; import { RecentProjectsSection } from "./sections/RecentProjectsSection"; import { TemplatesSection } from "./sections/TemplatesSection"; export function HomeShell() { + const tab = useHomeStore((s) => s.tab); + return ( <div className="home-shell"> <HomeRail /> <main className="home-main"> - <HomeSearch /> - <ContinueSection /> - <RecentProjectsSection /> - <LessonPathSection /> - <TemplatesSection /> - <BottomTip /> + {tab === "accueil" && ( + <> + <HomeSearch /> + <ContinueSection /> + <RecentProjectsSection /> + <LessonPathSection /> + <TemplatesSection /> + <BottomTip /> + </> + )} + {tab === "lessons" && <LessonsListView />} + {tab === "challenges" && <ChallengesListView />} </main> <ProjectModalsHost /> </div> diff --git a/src/home/homeStore.ts b/src/home/homeStore.ts new file mode 100644 index 0000000..1cfe640 --- /dev/null +++ b/src/home/homeStore.ts @@ -0,0 +1,13 @@ +import { create } from "zustand"; + +export type HomeTab = "accueil" | "lessons" | "challenges"; + +interface HomeState { + tab: HomeTab; + setTab: (tab: HomeTab) => void; +} + +export const useHomeStore = create<HomeState>((set) => ({ + tab: "accueil", + setTab: (tab) => set({ tab }), +})); diff --git a/src/home/icons.tsx b/src/home/icons.tsx index 3916f01..502e425 100644 --- a/src/home/icons.tsx +++ b/src/home/icons.tsx @@ -72,3 +72,37 @@ export function IconChevronRight() { </Svg> ); } + +export function IconBook() { + return ( + <Svg> + <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" /> + <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2Z" /> + </Svg> + ); +} + +export function IconLightning() { + return ( + <Svg> + <path d="M13 2 3 14h9l-1 8 10-12h-9l1-8Z" /> + </Svg> + ); +} + +export function IconList() { + return ( + <Svg> + <path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" /> + </Svg> + ); +} + +export function IconGear() { + return ( + <Svg> + <circle cx="12" cy="12" r="3" /> + <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" /> + </Svg> + ); +} diff --git a/src/home/rail/HomeNav.tsx b/src/home/rail/HomeNav.tsx index f489276..e844d2f 100644 --- a/src/home/rail/HomeNav.tsx +++ b/src/home/rail/HomeNav.tsx @@ -1,10 +1,44 @@ -import { IconHome } from "../icons"; +import { LESSONS } from "../../lessons/data"; +import { useLessonStore } from "../../lessons/lessonStore"; +import { useHomeStore } from "../homeStore"; +import { IconBook, IconGear, IconHome, IconLightning, IconList } from "../icons"; import { HomeNavItem } from "./HomeNavItem"; export function HomeNav() { + const tab = useHomeStore((s) => s.tab); + const setTab = useHomeStore((s) => s.setTab); + const checkpointStates = useLessonStore((s) => s.checkpointStates); + const activeLessonId = useLessonStore((s) => s.activeLessonId); + + const completed = LESSONS.filter((lesson) => { + if (lesson.id !== activeLessonId) return false; + const allIds = lesson.steps.flatMap((step) => step.checkpoints.map((cp) => cp.id)); + return allIds.length > 0 && allIds.every((id) => checkpointStates[id] === "done"); + }).length; + return ( <nav className="home-nav" aria-label="Navigation principale"> - <HomeNavItem icon={<IconHome />} label="Accueil" active /> + <HomeNavItem + icon={<IconHome />} + label="Accueil" + active={tab === "accueil"} + onClick={() => setTab("accueil")} + /> + <HomeNavItem + icon={<IconBook />} + label="Leçons" + active={tab === "lessons"} + onClick={() => setTab("lessons")} + counter={`${completed} / ${LESSONS.length}`} + /> + <HomeNavItem + icon={<IconLightning />} + label="Challenges" + active={tab === "challenges"} + onClick={() => setTab("challenges")} + /> + <HomeNavItem icon={<IconList />} label="Cheatsheets" /> + <HomeNavItem icon={<IconGear />} label="Settings" /> </nav> ); } diff --git a/src/home/rail/HomeNavItem.tsx b/src/home/rail/HomeNavItem.tsx index df692d2..dbabfad 100644 --- a/src/home/rail/HomeNavItem.tsx +++ b/src/home/rail/HomeNavItem.tsx @@ -5,9 +5,10 @@ interface Props { label: string; active?: boolean; onClick?: () => void; + counter?: string; } -export function HomeNavItem({ icon, label, active = false, onClick }: Props) { +export function HomeNavItem({ icon, label, active = false, onClick, counter }: Props) { return ( <button type="button" @@ -17,6 +18,7 @@ export function HomeNavItem({ icon, label, active = false, onClick }: Props) { > {icon} <span>{label}</span> + {counter !== undefined && <span className="home-nav-item__counter">{counter}</span>} </button> ); } diff --git a/src/home/sections/ChallengesListView.tsx b/src/home/sections/ChallengesListView.tsx new file mode 100644 index 0000000..5c5f6f5 --- /dev/null +++ b/src/home/sections/ChallengesListView.tsx @@ -0,0 +1,46 @@ +import { CHALLENGES } from "../../lessons/data"; +import { useLessonStore } from "../../lessons/lessonStore"; +import { LessonCard } from "./LessonCard"; + +export function ChallengesListView() { + const activeLessonId = useLessonStore((s) => s.activeLessonId); + const checkpointStates = useLessonStore((s) => s.checkpointStates); + + const completed = CHALLENGES.filter((challenge) => { + if (challenge.id !== activeLessonId) return false; + const allIds = challenge.steps.flatMap((step) => step.checkpoints.map((cp) => cp.id)); + return allIds.length > 0 && allIds.every((id) => checkpointStates[id] === "done"); + }).length; + + return ( + <section className="home-section"> + <div className="home-sec-head"> + <h3>Tous les challenges</h3> + <span className="home-sec-count"> + {completed} / {CHALLENGES.length} + </span> + </div> + <div className="lesson-grid"> + {CHALLENGES.map((challenge) => { + const allIds = challenge.steps.flatMap((step) => step.checkpoints.map((cp) => cp.id)); + const doneCount = allIds.filter((id) => checkpointStates[id] === "done").length; + const isActive = challenge.id === activeLessonId; + + const status: "not-started" | "completed" = + isActive && allIds.length > 0 && doneCount === allIds.length + ? "completed" + : "not-started"; + + return ( + <LessonCard + key={challenge.id} + lesson={challenge} + status={status} + onStart={() => useLessonStore.getState().startLesson(challenge.id)} + /> + ); + })} + </div> + </section> + ); +} diff --git a/src/home/sections/ChangelogModal.tsx b/src/home/sections/ChangelogModal.tsx index 7eae134..b84ea90 100644 --- a/src/home/sections/ChangelogModal.tsx +++ b/src/home/sections/ChangelogModal.tsx @@ -7,20 +7,27 @@ interface Props { onClose: () => void; } -// Minimal markdown-to-HTML for the changelog. Handles headings, bold, lists, -// horizontal rules and paragraphs — enough for a Keep a Changelog file. +function escapeHtml(text: string): string { + return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); +} + +function applyInline(text: string): string { + return escapeHtml(text) + .replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>") + .replace(/`(.+?)`/g, '<span class="inline-code">$1</span>'); +} + function renderMarkdown(md: string): string { return md .split("\n") .map((line) => { - if (line.startsWith("# ")) return `<h2 class="cl-h1">${line.slice(2)}</h2>`; - if (line.startsWith("## ")) return `<h3 class="cl-h2">${line.slice(3)}</h3>`; - if (line.startsWith("### ")) return `<h4 class="cl-h3">${line.slice(4)}</h4>`; + if (line.startsWith("# ")) return `<h2 class="cl-h1">${applyInline(line.slice(2))}</h2>`; + if (line.startsWith("## ")) return `<h3 class="cl-h2">${applyInline(line.slice(3))}</h3>`; + if (line.startsWith("### ")) return `<h4 class="cl-h3">${applyInline(line.slice(4))}</h4>`; if (line.startsWith("---")) return '<hr class="cl-hr"/>'; - if (line.startsWith("- ")) - return `<li class="cl-li">${line.slice(2).replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")}</li>`; + if (line.startsWith("- ")) return `<li class="cl-li">${applyInline(line.slice(2))}</li>`; if (line.trim() === "") return ""; - return `<p class="cl-p">${line.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")}</p>`; + return `<p class="cl-p">${applyInline(line)}</p>`; }) .join("\n"); } diff --git a/src/home/sections/ContinueLessonCard.tsx b/src/home/sections/ContinueLessonCard.tsx new file mode 100644 index 0000000..22b4371 --- /dev/null +++ b/src/home/sections/ContinueLessonCard.tsx @@ -0,0 +1,39 @@ +import { IconChevronRight } from "../icons"; +import { useLessonStore } from "../../lessons/lessonStore"; +import type { LessonData } from "../../lessons/types"; + +interface Props { + lesson: LessonData; + doneCount: number; + totalCount: number; +} + +export function ContinueLessonCard({ lesson, doneCount, totalCount }: Props) { + return ( + <div className="home-continue"> + <div className="home-continue-main"> + <div className="home-continue-eyebrow"> + <span className="home-continue-pulse" aria-hidden="true" /> + Reprends là où tu t'étais arrêté + </div> + <h2 className="home-continue-title">{lesson.title}</h2> + <p className="home-continue-sub"> + <span className="home-continue-meta"> + {lesson.type === "lesson" ? "Leçon" : "Challenge"} · checkpoint {doneCount} /{" "} + {totalCount} + </span> + </p> + </div> + <div className="home-continue-actions"> + <button + type="button" + className="home-btn home-btn--primary" + onClick={() => useLessonStore.getState().startLesson(lesson.id)} + > + Continuer + <IconChevronRight /> + </button> + </div> + </div> + ); +} diff --git a/src/home/sections/ContinueSection.tsx b/src/home/sections/ContinueSection.tsx index 8b09a7c..0028c60 100644 --- a/src/home/sections/ContinueSection.tsx +++ b/src/home/sections/ContinueSection.tsx @@ -1,21 +1,46 @@ import { useProjectStore } from "../../projects/projectStore"; +import { useLessonStore } from "../../lessons/lessonStore"; +import { getLessonById } from "../../lessons/data"; import { IconClock } from "../icons"; import { ContinueCard } from "./ContinueCard"; +import { ContinueLessonCard } from "./ContinueLessonCard"; import { EmptyState } from "./EmptyState"; export function ContinueSection() { const projects = useProjectStore((s) => s.projects); + const activeLessonId = useLessonStore((s) => s.activeLessonId); + const checkpointStates = useLessonStore((s) => s.checkpointStates); + const project = projects.length === 0 ? null : ([...projects].sort((a, b) => b.lastOpenedAt - a.lastOpenedAt)[0] ?? null); + // Determine if there is a lesson in progress (at least one done, but not all done) + const activeLesson = activeLessonId !== null ? getLessonById(activeLessonId) : undefined; + const lessonInProgress = (() => { + if (!activeLesson) return null; + const allCheckpointIds = activeLesson.steps.flatMap((step) => + step.checkpoints.map((cp) => cp.id), + ); + const totalCount = allCheckpointIds.length; + const doneCount = allCheckpointIds.filter((id) => checkpointStates[id] === "done").length; + if (doneCount === 0 || doneCount >= totalCount) return null; + return { lesson: activeLesson, doneCount, totalCount }; + })(); + return ( <section className="home-section"> <div className="home-sec-head"> <h3>Reprends là où tu t'étais arrêté</h3> </div> - {project ? ( + {lessonInProgress ? ( + <ContinueLessonCard + lesson={lessonInProgress.lesson} + doneCount={lessonInProgress.doneCount} + totalCount={lessonInProgress.totalCount} + /> + ) : project ? ( <ContinueCard project={project} /> ) : ( <EmptyState diff --git a/src/home/sections/LessonCard.tsx b/src/home/sections/LessonCard.tsx new file mode 100644 index 0000000..ae1d46c --- /dev/null +++ b/src/home/sections/LessonCard.tsx @@ -0,0 +1,55 @@ +import type { LessonData } from "../../lessons/types"; + +interface Props { + lesson: LessonData; + status: "not-started" | "in-progress" | "completed"; + completedCheckpoints?: number; + totalCheckpoints?: number; + onStart: () => void; +} + +export function LessonCard({ + lesson, + status, + completedCheckpoints = 0, + totalCheckpoints = 0, + onStart, +}: Props) { + const progressRatio = + status === "in-progress" && totalCheckpoints > 0 ? completedCheckpoints / totalCheckpoints : 0; + + return ( + <button + type="button" + className={`lesson-card${status === "in-progress" ? " lesson-card--in-progress" : ""}`} + onClick={onStart} + aria-label={`Commencer ${lesson.title}`} + > + <div className="lesson-card__header"> + {status === "completed" && ( + <span className="lesson-card__badge lesson-card__badge--done">✓ terminé</span> + )} + {status === "in-progress" && ( + <span className="lesson-card__badge lesson-card__badge--active">● en cours</span> + )} + {status === "not-started" && ( + <span className="lesson-card__badge lesson-card__badge--todo">pas commencé</span> + )} + <span className="lesson-card__time">~{lesson.estimatedMinutes} min</span> + </div> + + <div className="lesson-card__title">{lesson.title}</div> + + <div className="lesson-card__desc">{lesson.description}</div> + + {status === "in-progress" && totalCheckpoints > 0 && ( + <div className="lesson-card__progress"> + <div + className="lesson-card__progress-fill" + style={{ width: `${progressRatio * 100}%` }} + /> + </div> + )} + </button> + ); +} diff --git a/src/home/sections/LessonsListView.tsx b/src/home/sections/LessonsListView.tsx new file mode 100644 index 0000000..1d944ae --- /dev/null +++ b/src/home/sections/LessonsListView.tsx @@ -0,0 +1,52 @@ +import { LESSONS } from "../../lessons/data"; +import { useLessonStore } from "../../lessons/lessonStore"; +import { LessonCard } from "./LessonCard"; + +export function LessonsListView() { + const activeLessonId = useLessonStore((s) => s.activeLessonId); + const checkpointStates = useLessonStore((s) => s.checkpointStates); + + const completed = LESSONS.filter((lesson) => { + if (lesson.id !== activeLessonId) return false; + const allIds = lesson.steps.flatMap((step) => step.checkpoints.map((cp) => cp.id)); + return allIds.length > 0 && allIds.every((id) => checkpointStates[id] === "done"); + }).length; + + return ( + <section className="home-section"> + <div className="home-sec-head"> + <h3>Toutes les leçons</h3> + <span className="home-sec-count"> + {completed} / {LESSONS.length} + </span> + </div> + <div className="lesson-grid"> + {LESSONS.map((lesson) => { + const allIds = lesson.steps.flatMap((step) => step.checkpoints.map((cp) => cp.id)); + const doneCount = allIds.filter((id) => checkpointStates[id] === "done").length; + const isActive = lesson.id === activeLessonId; + + let status: "not-started" | "in-progress" | "completed"; + if (isActive && doneCount > 0 && doneCount < allIds.length) { + status = "in-progress"; + } else if (isActive && allIds.length > 0 && doneCount === allIds.length) { + status = "completed"; + } else { + status = "not-started"; + } + + return ( + <LessonCard + key={lesson.id} + lesson={lesson} + status={status} + completedCheckpoints={doneCount} + totalCheckpoints={allIds.length} + onStart={() => useLessonStore.getState().startLesson(lesson.id)} + /> + ); + })} + </div> + </section> + ); +} diff --git a/src/ide/dock/LessonDock.tsx b/src/ide/dock/LessonDock.tsx index 4d1541b..6307113 100644 --- a/src/ide/dock/LessonDock.tsx +++ b/src/ide/dock/LessonDock.tsx @@ -1,92 +1,115 @@ import { useIdeStore } from "../../state/ideStore"; -import { MOCK_LESSON, type Checkpoint, type LessonParagraph } from "./mockLesson"; +import { useLessonContext } from "../../lessons/useLessonContext"; +import type { CheckpointDef, LessonParagraph } from "../../lessons/types"; +import type { CheckpointStatus } from "../../lessons/types"; import { ProgressRing } from "./ProgressRing"; export function LessonDock() { - const collapsed = useIdeStore((s) => s.dockCollapsed); - const setCollapsed = useIdeStore((s) => s.setDockCollapsed); - const lesson = MOCK_LESSON; - const done = lesson.checkpoints.filter((cp) => cp.status === "done").length; - const total = lesson.checkpoints.length; - const progress = total === 0 ? 0 : done / total; + const ctx = useLessonContext(); + const dockCollapsed = useIdeStore((s) => s.dockCollapsed); + const setDockCollapsed = useIdeStore((s) => s.setDockCollapsed); + + if (!ctx) return null; + + const { lesson, checkpoints, currentStepIndex, progress } = ctx; + const step = lesson.steps[currentStepIndex]; + if (!step) return null; + + const doneCount = checkpoints.filter((c) => c.status === "done").length; + const totalCount = checkpoints.length; + + const toggleDock = () => setDockCollapsed(!dockCollapsed); return ( - <aside className={collapsed ? "dock dock--collapsed" : "dock"}> + <aside className={`dock${dockCollapsed ? " collapsed" : ""}`}> <button type="button" className="dock-head" - onClick={() => setCollapsed(!collapsed)} - aria-expanded={!collapsed} + onClick={toggleDock} + aria-expanded={!dockCollapsed} > <svg className="i-lg chev" viewBox="0 0 24 24" aria-hidden="true"> <path d="m6 9 6 6 6-6" /> </svg> <span className="lesson-chip"> - <span className="num">{lesson.chipNumber}</span> - {lesson.chipLabel} + <span className="num">{currentStepIndex + 1}</span> + {lesson.type === "lesson" ? "Leçon" : "Challenge"} · {step.heading} </span> <div className="progress"> <span> - {done} / {total} points + {doneCount} / {totalCount} points </span> <ProgressRing value={progress} /> </div> </button> - - {!collapsed && ( - <div className="dock-body"> - <div className="lesson-text"> - <h2>{lesson.heading}</h2> - {lesson.paragraphs.map((p, i) => ( - <LessonParagraphView key={i} paragraph={p} /> - ))} - {lesson.hintFooter && ( - <p className="lesson-hint"> - <svg className="i" viewBox="0 0 24 24" aria-hidden="true"> - <circle cx="12" cy="12" r="9" /> - <path d="M12 8v4M12 16h.01" /> - </svg> - {lesson.hintFooter} - </p> - )} - </div> - - <div className="checkpoints"> - {lesson.checkpoints.map((cp) => ( - <CheckpointRow key={cp.id} checkpoint={cp} /> - ))} - </div> + <div className="dock-body"> + <div className="lesson-text"> + <h2>{step.heading}</h2> + {step.paragraphs.map((p, i) => ( + <ParagraphView key={i} paragraph={p} /> + ))} + <p className="lesson-hint"> + <svg className="i" viewBox="0 0 24 24" aria-hidden="true"> + <circle cx="12" cy="12" r="9" /> + <path d="M12 8v4M12 16h.01" /> + </svg> + Survole un mot-clé dans l'éditeur pour une explication rapide. + </p> + </div> + <div className="checkpoints"> + {step.checkpoints.map((cp) => { + const state = checkpoints.find((c) => c.id === cp.id); + return <CheckpointRow key={cp.id} checkpoint={cp} status={state?.status ?? "todo"} />; + })} </div> - )} + </div> </aside> ); } -function LessonParagraphView({ paragraph }: { paragraph: LessonParagraph }) { - return ( - <p> - {paragraph.parts.map((part, i) => - part.kind === "code" ? ( - <span key={i} className="inline-code"> - {part.value} - </span> - ) : ( - <span key={i}>{part.value}</span> - ), - )} - </p> +/** Split backtick-delimited segments into plain text and inline-code spans. */ +function renderInlineCode(text: string) { + const parts = text.split("`"); + return parts.map((part, i) => + i % 2 === 1 ? ( + <span key={i} className="inline-code"> + {part} + </span> + ) : ( + <span key={i}>{part}</span> + ), ); } -function CheckpointRow({ checkpoint }: { checkpoint: Checkpoint }) { - const metaClass = checkpoint.status === "active" ? "meta meta--live" : "meta"; +function ParagraphView({ paragraph }: { paragraph: LessonParagraph }) { + if (paragraph.kind === "code") { + return ( + <pre> + <code>{paragraph.content}</code> + </pre> + ); + } + return <p>{renderInlineCode(paragraph.content)}</p>; +} + +function CheckpointRow({ + checkpoint, + status, +}: { + checkpoint: CheckpointDef; + status: CheckpointStatus; +}) { + const cls = `cp${status === "done" ? " done" : status === "active" ? " active" : ""}`; + const metaCls = `meta${status === "active" ? " live" : ""}`; + const metaText = status === "done" ? "fait" : status === "active" ? "vérification…" : "—"; + return ( - <div className={`cp cp--${checkpoint.status}`}> + <div className={cls}> <span className="tick" aria-hidden="true"> ✓ </span> - <span className="label">{checkpoint.label}</span> - <span className={metaClass}>{checkpoint.meta ?? ""}</span> + <span className="label">{renderInlineCode(checkpoint.label)}</span> + <span className={metaCls}>{metaText}</span> </div> ); } diff --git a/src/ide/dock/mockLesson.ts b/src/ide/dock/mockLesson.ts deleted file mode 100644 index 6509024..0000000 --- a/src/ide/dock/mockLesson.ts +++ /dev/null @@ -1,83 +0,0 @@ -export type CheckpointStatus = "done" | "active" | "todo"; - -export interface Checkpoint { - id: string; - label: string; - status: CheckpointStatus; - meta?: string; -} - -export type LessonPart = { kind: "text"; value: string } | { kind: "code"; value: string }; - -export interface LessonParagraph { - parts: LessonPart[]; -} - -export interface LessonContent { - chipNumber: string; - chipLabel: string; - heading: string; - paragraphs: LessonParagraph[]; - checkpoints: Checkpoint[]; - hintFooter?: string; -} - -/** - * Mock content for PR 2 — dock UI shell only. No validation, no progression - * logic. When the real lesson engine lands (separate milestone), this file - * goes away and the dock consumes a proper `Lesson` loaded from disk or - * remote. - */ -export const MOCK_LESSON: LessonContent = { - chipNumber: "3", - chipLabel: "Leçon · Mettre en forme la page", - heading: "Donne un style à ta page", - paragraphs: [ - { - parts: [ - { kind: "text", value: "Tu as monté le squelette avec du " }, - { kind: "code", value: "HTML" }, - { kind: "text", value: ". Passons maintenant au " }, - { kind: "code", value: "CSS" }, - { - kind: "text", - value: - " pour contrôler l'aspect visuel — couleurs, typographie, espacements. Le CSS vit dans un fichier séparé, lié depuis ton HTML.", - }, - ], - }, - { - parts: [ - { kind: "text", value: "Lie " }, - { kind: "code", value: "style.css" }, - { kind: "text", value: " dans le " }, - { kind: "code", value: "<head>" }, - { kind: "text", value: " de ta page avec une balise " }, - { kind: "code", value: "<link>" }, - { kind: "text", value: ". L'aperçu se met à jour dès que tu modifies le code." }, - ], - }, - ], - checkpoints: [ - { - id: "create-stylesheet", - label: "Créer un fichier nommé style.css", - status: "done", - meta: "fait", - }, - { id: "body-selector", label: "Ajouter un sélecteur body", status: "done", meta: "fait" }, - { - id: "link-stylesheet", - label: "Lier style.css depuis index.html", - status: "active", - meta: "vérification…", - }, - { - id: "change-bg", - label: "Changer la couleur de fond de la page", - status: "todo", - meta: "—", - }, - ], - hintFooter: "Survole un mot-clé dans l'éditeur pour une explication rapide.", -}; diff --git a/src/ide/shell/StatusBar.tsx b/src/ide/shell/StatusBar.tsx index 5439609..e4f16cf 100644 --- a/src/ide/shell/StatusBar.tsx +++ b/src/ide/shell/StatusBar.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { APP_VERSION } from "../../constants/version"; +import { useLessonContext } from "../../lessons/useLessonContext"; import { useIdeStore } from "../../state/ideStore"; import { bridgeEvents } from "../../tauri/bridge"; import { persistenceEvents } from "../../vfs/persistence"; @@ -9,6 +10,7 @@ import { formatSavedAgo, languageLabel } from "./statusBarFormat"; export function StatusBar() { const activeFile = useIdeStore((s) => s.activeFile); const cursor = useIdeStore((s) => s.editorCursor); + const lessonCtx = useLessonContext(); const [lastSyncMs, setLastSyncMs] = useState<number | null>(null); const [lastSavedAt, setLastSavedAt] = useState<number | null>(null); const [now, setNow] = useState<number>(() => Date.now()); @@ -72,7 +74,14 @@ export function StatusBar() { preview · {lastSyncMs} ms </span> )} - <span className="chip">{APP_VERSION}</span> + {lessonCtx ? ( + <span className="chip accent"> + {lessonCtx.checkpoints.filter((cp) => cp.status === "done").length} /{" "} + {lessonCtx.checkpoints.length} points + </span> + ) : ( + <span className="chip">{APP_VERSION}</span> + )} </footer> ); } diff --git a/src/ide/shell/Toolbar.tsx b/src/ide/shell/Toolbar.tsx index d5c9c92..1b7df6d 100644 --- a/src/ide/shell/Toolbar.tsx +++ b/src/ide/shell/Toolbar.tsx @@ -1,14 +1,17 @@ import { LogoMark } from "../../components/LogoMark"; +import { useLessonContext } from "../../lessons/useLessonContext"; import { useIdeStore } from "../../state/ideStore"; export function Toolbar() { const setView = useIdeStore((s) => s.setView); + const lessonCtx = useLessonContext(); + const handleBrandClick = lessonCtx ? () => lessonCtx.exitLesson() : () => setView("home"); return ( <header className="toolbar" role="banner"> <button type="button" className="brand brand--link" - onClick={() => setView("home")} + onClick={handleBrandClick} aria-label="Retour à l'accueil" > <LogoMark size={20} /> diff --git a/src/ide/shell/shortcuts.ts b/src/ide/shell/shortcuts.ts index a88d999..086cf21 100644 --- a/src/ide/shell/shortcuts.ts +++ b/src/ide/shell/shortcuts.ts @@ -42,7 +42,8 @@ export function isTypingInField(target: EventTarget | null): boolean { export function useGlobalShortcuts(): void { useEffect(() => { const onKey = (e: KeyboardEvent) => { - if (useIdeStore.getState().view !== "ide") return; + const view = useIdeStore.getState().view; + if (view !== "ide" && view !== "lesson") return; if (e.isComposing) return; const mod = e.ctrlKey || e.metaKey; if (!mod) return; @@ -61,6 +62,7 @@ export function useGlobalShortcuts(): void { if (e.key === "n" || e.key === "N") { if (isTypingInField(e.target)) return; + if (view === "lesson") return; e.preventDefault(); shortcutEvents.dispatchEvent(new Event(SHORTCUT_NEW_FILE)); return; diff --git a/src/ide/tree/FileTree.tsx b/src/ide/tree/FileTree.tsx index 818b2b4..6878d47 100644 --- a/src/ide/tree/FileTree.tsx +++ b/src/ide/tree/FileTree.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useModalA11y } from "../../hooks/useModalA11y"; +import { useLessonContext } from "../../lessons/useLessonContext"; import { useIdeStore } from "../../state/ideStore"; import { vfs } from "../../vfs/VirtualFS"; import { ConfirmDialog } from "../shell/ConfirmDialog"; @@ -31,6 +32,8 @@ export function FileTree() { const [pendingDelete, setPendingDelete] = useState<string | null>(null); const openFile = useIdeStore((s) => s.openFile); const activeFile = useIdeStore((s) => s.activeFile); + const lessonCtx = useLessonContext(); + const fileOpsLocked = lessonCtx?.fileOpsLocked ?? false; // Only restructure the tree when file *set* changes. Ignore plain writes — // those fire on every keystroke in the editor and would force rebuildTree @@ -93,7 +96,7 @@ export function FileTree() { }; const menuItems: ContextMenuItem[] = menu - ? buildMenuItems(menu.target, { + ? buildMenuItems(menu.target, fileOpsLocked, { startCreate: (parentPath) => setPrompt({ kind: "create", parentPath, initial: "" }), startRename: (path) => { const parent = path.includes("/", 1) ? path.slice(0, path.lastIndexOf("/")) || "/" : "/"; @@ -140,19 +143,21 @@ export function FileTree() { <div className="sb-rail"> <span className="label">Fichiers</span> <div className="tools"> - <button - type="button" - className="ico-btn" - title="Nouveau fichier" - aria-label="Nouveau fichier" - onClick={startNewFileAtRoot} - > - <svg className="i" viewBox="0 0 24 24" aria-hidden="true"> - <path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" /> - <path d="M14 3v5h5" /> - <path d="M12 12v6M9 15h6" /> - </svg> - </button> + {!fileOpsLocked && ( + <button + type="button" + className="ico-btn" + title="Nouveau fichier" + aria-label="Nouveau fichier" + onClick={startNewFileAtRoot} + > + <svg className="i" viewBox="0 0 24 24" aria-hidden="true"> + <path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" /> + <path d="M14 3v5h5" /> + <path d="M12 12v6M9 15h6" /> + </svg> + </button> + )} </div> </div> <ul className="file-tree__list"> @@ -274,12 +279,22 @@ function collectVisibleFiles(nodes: TreeNode[]): string[] { function buildMenuItems( target: MenuState["target"], + fileOpsLocked: boolean, actions: { startCreate: (parentPath: string) => void; startRename: (path: string) => void; deletePath: (path: string) => void; }, ): ContextMenuItem[] { + if (fileOpsLocked) { + if (target.kind === "folder") return []; + return [ + { + label: "Ouvrir", + onSelect: () => useIdeStore.getState().openFile(target.path), + }, + ]; + } if (target.kind === "folder") { return [{ label: "Nouveau fichier", onSelect: () => actions.startCreate(target.path) }]; } diff --git a/src/lessons/LessonProvider.tsx b/src/lessons/LessonProvider.tsx new file mode 100644 index 0000000..b55becf --- /dev/null +++ b/src/lessons/LessonProvider.tsx @@ -0,0 +1,97 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; + +import { vfs } from "../vfs/VirtualFS"; +import { getLessonById } from "./data"; +import { useLessonStore } from "./lessonStore"; +import { markLessonCompleted } from "./progressPersistence"; +import type { CheckpointState } from "./types"; +import { LessonContext } from "./useLessonContext"; +import type { LessonContextValue } from "./useLessonContext"; +import { validateCheckpoints } from "./validation/validate"; + +interface LessonProviderProps { + children: React.ReactNode; +} + +export function LessonProvider({ children }: LessonProviderProps) { + const activeLessonId = useLessonStore((s) => s.activeLessonId); + const checkpointStates = useLessonStore((s) => s.checkpointStates); + const lesson = activeLessonId ? getLessonById(activeLessonId) : undefined; + + const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); + + const runValidation = useCallback(() => { + if (!lesson) return; + + const allCheckpoints = lesson.steps.flatMap((step) => step.checkpoints); + const files = vfs.snapshot(); + const results = validateCheckpoints(allCheckpoints, files); + const { completeCheckpoint, checkpointStates: currentStates } = useLessonStore.getState(); + + for (const result of results) { + if (result.passed && currentStates[result.checkpointId] !== "done") { + completeCheckpoint(result.checkpointId); + } + } + + // Check if all checkpoints are now done → persist completion + const updatedStates = useLessonStore.getState().checkpointStates; + const allDone = allCheckpoints.every((cp) => updatedStates[cp.id] === "done"); + if (allDone && activeLessonId) { + const doneIds = allCheckpoints.map((cp) => cp.id); + markLessonCompleted(activeLessonId, doneIds).catch((err) => + console.error("Failed to persist lesson completion", err), + ); + } + }, [lesson, activeLessonId]); + + // Subscribe to VFS changes with 300ms debounce + useEffect(() => { + if (!lesson) return; + + // Run an initial validation on mount + runValidation(); + + const unsub = vfs.on("change", () => { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(runValidation, 300); + }); + + return () => { + unsub(); + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [lesson, runValidation]); + + const value = useMemo((): LessonContextValue | null => { + if (!lesson) return null; + + const allCheckpoints = lesson.steps.flatMap((step) => step.checkpoints); + + const checkpoints: CheckpointState[] = allCheckpoints.map((cp) => ({ + id: cp.id, + status: checkpointStates[cp.id] ?? "todo", + })); + + const doneCount = checkpoints.filter((cp) => cp.status === "done").length; + const total = checkpoints.length; + const progress = total > 0 ? doneCount / total : 0; + + return { + lesson, + checkpoints, + currentStepIndex: 0, + fileOpsLocked: !lesson.allowFileOps, + progress, + validateNow: runValidation, + exitLesson: () => useLessonStore.getState().exitLesson(), + }; + }, [lesson, checkpointStates, runValidation]); + + if (!value) return null; + + return <LessonContext.Provider value={value}>{children}</LessonContext.Provider>; +} diff --git a/src/lessons/data/challenge-01-simple-page.json b/src/lessons/data/challenge-01-simple-page.json new file mode 100644 index 0000000..2920e24 --- /dev/null +++ b/src/lessons/data/challenge-01-simple-page.json @@ -0,0 +1,48 @@ +{ + "id": "challenge-01-simple-page", + "type": "challenge", + "title": "Crée une page simple", + "description": "Un titre, un paragraphe et une couleur de fond — à toi de jouer.", + "difficulty": "débutant", + "estimatedMinutes": 10, + "tags": ["html", "css"], + "allowFileOps": true, + "starterFiles": { + "/index.html": "<!DOCTYPE html>\n<html>\n<head>\n <link rel=\"stylesheet\" href=\"style.css\">\n</head>\n<body>\n</body>\n</html>", + "/style.css": "" + }, + "steps": [ + { + "heading": "Objectif", + "paragraphs": [ + { + "kind": "text", + "content": "Construis une page complète : ajoute un titre <h1> et un paragraphe <p> dans le HTML, puis ouvre style.css et donne une couleur de fond à la page en ciblant body avec la propriété background-color." + } + ], + "checkpoints": [ + { + "id": "has-h1", + "label": "Ajoute un titre <h1>", + "rule": { "type": "element-exists", "selector": "h1", "file": "/index.html" } + }, + { + "id": "has-p", + "label": "Ajoute un paragraphe <p>", + "rule": { "type": "element-exists", "selector": "p", "file": "/index.html" } + }, + { + "id": "has-bg", + "label": "Donne une couleur de fond à la page", + "rule": { + "type": "css-property", + "selector": "body", + "file": "/style.css", + "property": "background-color", + "match": "exists" + } + } + ] + } + ] +} diff --git a/src/lessons/data/html-01-structure.json b/src/lessons/data/html-01-structure.json new file mode 100644 index 0000000..5e49bfa --- /dev/null +++ b/src/lessons/data/html-01-structure.json @@ -0,0 +1,55 @@ +{ + "id": "html-01-structure", + "type": "lesson", + "title": "La structure d'une page HTML", + "description": "Découvre les balises de base : html, head, body, titre et paragraphe.", + "difficulty": "débutant", + "estimatedMinutes": 10, + "tags": ["html"], + "allowFileOps": false, + "starterFiles": { + "/index.html": "<!DOCTYPE html>\n<html>\n</html>" + }, + "steps": [ + { + "heading": "Construis le squelette", + "paragraphs": [ + { + "kind": "text", + "content": "Une page HTML est organisée en deux grandes parties : le <head> contient les informations invisibles (comme le titre affiché dans l'onglet), et le <body> contient tout ce que le visiteur voit. À l'intérieur du <head>, place un <title> pour nommer ta page. Dans le <body>, utilise <h1> pour un titre visible et <p> pour un paragraphe de texte." + }, + { + "kind": "code", + "content": "<!DOCTYPE html>\n<html>\n <head>\n <title>Ma page\n \n \n

Bonjour !

\n

Voici mon premier paragraphe.

\n \n" + } + ], + "checkpoints": [ + { + "id": "has-head", + "label": "Ajoute une balise ", + "rule": { "type": "element-exists", "selector": "head", "file": "/index.html" } + }, + { + "id": "has-body", + "label": "Ajoute une balise ", + "rule": { "type": "element-exists", "selector": "body", "file": "/index.html" } + }, + { + "id": "has-title", + "label": "Ajoute un dans le <head>", + "rule": { "type": "nesting", "parent": "head", "child": "title", "file": "/index.html" } + }, + { + "id": "has-h1", + "label": "Ajoute un titre <h1> dans le <body>", + "rule": { "type": "nesting", "parent": "body", "child": "h1", "file": "/index.html" } + }, + { + "id": "has-p", + "label": "Ajoute un paragraphe <p>", + "rule": { "type": "element-exists", "selector": "p", "file": "/index.html" } + } + ] + } + ] +} diff --git a/src/lessons/data/index.ts b/src/lessons/data/index.ts new file mode 100644 index 0000000..599d1ce --- /dev/null +++ b/src/lessons/data/index.ts @@ -0,0 +1,11 @@ +import type { LessonData } from "../types"; +import html01 from "./html-01-structure.json"; +import challenge01 from "./challenge-01-simple-page.json"; + +export const LESSONS: readonly LessonData[] = [html01 as LessonData]; +export const CHALLENGES: readonly LessonData[] = [challenge01 as LessonData]; +export const ALL_CONTENT: readonly LessonData[] = [...LESSONS, ...CHALLENGES]; + +export function getLessonById(id: string): LessonData | undefined { + return ALL_CONTENT.find((l) => l.id === id); +} diff --git a/src/lessons/lessonStore.test.ts b/src/lessons/lessonStore.test.ts new file mode 100644 index 0000000..a5b0035 --- /dev/null +++ b/src/lessons/lessonStore.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { useIdeStore } from "../state/ideStore"; +import { vfs } from "../vfs/VirtualFS"; +import { useLessonStore } from "./lessonStore"; + +// syncVfsNow calls Tauri IPC which doesn't exist in Node test env. +vi.mock("../tauri/bridge", () => ({ + syncVfsNow: vi.fn().mockResolvedValue(undefined), +})); + +describe("useLessonStore", () => { + beforeEach(() => { + useLessonStore.setState({ activeLessonId: null, checkpointStates: {} }); + useIdeStore.setState({ view: "home", openFiles: [], activeFile: null }); + vfs.hydrate({}); + }); + + test("startLesson hydrates VFS and sets view to lesson", () => { + useLessonStore.getState().startLesson("html-01-structure"); + expect(useLessonStore.getState().activeLessonId).toBe("html-01-structure"); + expect(useIdeStore.getState().view).toBe("lesson"); + // Starter files should be in VFS + expect(vfs.listFiles().length).toBeGreaterThan(0); + }); + + test("startLesson initializes all checkpoints to todo", () => { + useLessonStore.getState().startLesson("html-01-structure"); + const states = useLessonStore.getState().checkpointStates; + expect(Object.keys(states).length).toBeGreaterThan(0); + for (const status of Object.values(states)) { + expect(status).toBe("todo"); + } + }); + + test("completeCheckpoint transitions a checkpoint to done", () => { + useLessonStore.getState().startLesson("html-01-structure"); + useLessonStore.getState().completeCheckpoint("has-head"); + expect(useLessonStore.getState().checkpointStates["has-head"]).toBe("done"); + }); + + test("exitLesson clears state and sets view to home", () => { + useLessonStore.getState().startLesson("html-01-structure"); + useLessonStore.getState().exitLesson(); + expect(useLessonStore.getState().activeLessonId).toBeNull(); + expect(useIdeStore.getState().view).toBe("home"); + }); + + test("resetLesson re-initializes checkpoints and re-hydrates VFS", () => { + useLessonStore.getState().startLesson("html-01-structure"); + useLessonStore.getState().completeCheckpoint("has-head"); + useLessonStore.getState().resetLesson("html-01-structure"); + expect(useLessonStore.getState().checkpointStates["has-head"]).toBe("todo"); + }); +}); diff --git a/src/lessons/lessonStore.ts b/src/lessons/lessonStore.ts new file mode 100644 index 0000000..d427c75 --- /dev/null +++ b/src/lessons/lessonStore.ts @@ -0,0 +1,69 @@ +import { create } from "zustand"; + +import { useIdeStore } from "../state/ideStore"; +import { syncVfsNow } from "../tauri/bridge"; +import { vfs } from "../vfs/VirtualFS"; +import { getLessonById } from "./data"; +import type { CheckpointStatus } from "./types"; + +interface LessonState { + activeLessonId: string | null; + checkpointStates: Record<string, CheckpointStatus>; + + startLesson: (id: string) => void; + completeCheckpoint: (id: string) => void; + resetLesson: (id: string) => void; + exitLesson: () => void; +} + +export const useLessonStore = create<LessonState>((set) => ({ + activeLessonId: null, + checkpointStates: {}, + + startLesson: (id) => { + const lesson = getLessonById(id); + if (!lesson) return; + + // Hydrate VFS with starter files and push the snapshot to the Rust + // preview cache so the iframe shows the lesson's starting state + // immediately (not a stale project or a blank page). + vfs.hydrate(lesson.starterFiles); + void syncVfsNow(vfs.snapshot()); + + // Initialize all checkpoints to "todo" + const states: Record<string, CheckpointStatus> = {}; + for (const step of lesson.steps) { + for (const cp of step.checkpoints) { + states[cp.id] = "todo"; + } + } + + set({ activeLessonId: id, checkpointStates: states }); + useIdeStore.getState().setView("lesson"); + }, + + completeCheckpoint: (id) => { + set((prev) => ({ + checkpointStates: { ...prev.checkpointStates, [id]: "done" }, + })); + }, + + resetLesson: (id) => { + const lesson = getLessonById(id); + if (!lesson) return; + const states: Record<string, CheckpointStatus> = {}; + for (const step of lesson.steps) { + for (const cp of step.checkpoints) { + states[cp.id] = "todo"; + } + } + vfs.hydrate(lesson.starterFiles); + void syncVfsNow(vfs.snapshot()); + set({ checkpointStates: states }); + }, + + exitLesson: () => { + set({ activeLessonId: null, checkpointStates: {} }); + useIdeStore.getState().setView("home"); + }, +})); diff --git a/src/lessons/progressPersistence.ts b/src/lessons/progressPersistence.ts new file mode 100644 index 0000000..580fde9 --- /dev/null +++ b/src/lessons/progressPersistence.ts @@ -0,0 +1,41 @@ +import { persistedStore } from "../tauri/persistedStore"; + +const PROGRESS_KEY = "progress.v1"; +const getStore = persistedStore("wecode.progress.json"); + +export interface ProgressData { + version: 1; + completed: Record<string, { completedAt: number }>; + checkpoints: Record<string, string[]>; +} + +export async function loadProgress(): Promise<ProgressData | null> { + const store = await getStore(); + const data = await store.get<ProgressData>(PROGRESS_KEY); + if (!data) return null; + if (data.version !== 1) return null; + return data; +} + +export async function saveProgress(data: ProgressData): Promise<void> { + const store = await getStore(); + await store.set(PROGRESS_KEY, data); + await store.save(); +} + +export async function markLessonCompleted( + lessonId: string, + checkpointIds: string[], +): Promise<void> { + const existing = await loadProgress(); + const data: ProgressData = existing ?? { version: 1, completed: {}, checkpoints: {} }; + data.completed[lessonId] = { completedAt: Date.now() }; + data.checkpoints[lessonId] = checkpointIds; + await saveProgress(data); +} + +export async function isLessonCompleted(lessonId: string): Promise<boolean> { + const data = await loadProgress(); + if (!data) return false; + return lessonId in data.completed; +} diff --git a/src/lessons/types.ts b/src/lessons/types.ts new file mode 100644 index 0000000..4ef2c9c --- /dev/null +++ b/src/lessons/types.ts @@ -0,0 +1,38 @@ +import type { ValidationRule } from "./validation/types"; + +export interface LessonParagraph { + kind: "text" | "code"; + content: string; +} + +export interface CheckpointDef { + id: string; + label: string; + rule: ValidationRule; +} + +export interface LessonStep { + heading: string; + paragraphs: LessonParagraph[]; + checkpoints: CheckpointDef[]; +} + +export interface LessonData { + id: string; + type: "lesson" | "challenge"; + title: string; + description: string; + difficulty: "débutant" | "intermédiaire" | "avancé"; + estimatedMinutes: number; + tags: string[]; + allowFileOps: boolean; + starterFiles: Record<string, string>; + steps: LessonStep[]; +} + +export type CheckpointStatus = "todo" | "active" | "done"; + +export interface CheckpointState { + id: string; + status: CheckpointStatus; +} diff --git a/src/lessons/useLessonContext.ts b/src/lessons/useLessonContext.ts new file mode 100644 index 0000000..53daf66 --- /dev/null +++ b/src/lessons/useLessonContext.ts @@ -0,0 +1,19 @@ +import { createContext, useContext } from "react"; + +import type { CheckpointState, LessonData } from "./types"; + +export interface LessonContextValue { + lesson: LessonData; + checkpoints: CheckpointState[]; + currentStepIndex: number; + fileOpsLocked: boolean; + progress: number; + validateNow: () => void; + exitLesson: () => void; +} + +export const LessonContext = createContext<LessonContextValue | null>(null); + +export function useLessonContext(): LessonContextValue | null { + return useContext(LessonContext); +} diff --git a/src/lessons/validation/rules/composite.ts b/src/lessons/validation/rules/composite.ts new file mode 100644 index 0000000..9db6c10 --- /dev/null +++ b/src/lessons/validation/rules/composite.ts @@ -0,0 +1,33 @@ +import type { ValidationRule } from "../types"; +import { checkElementExists } from "./elementExists"; +import { checkFileContains, checkFileNotContains } from "./fileContains"; +import { checkCssProperty } from "./cssProperty"; +import { checkNesting } from "./nesting"; + +export function evaluateRule(rule: ValidationRule, files: Record<string, string>): boolean { + switch (rule.type) { + case "element-exists": + return checkElementExists(rule, files); + case "file-contains": + return checkFileContains(rule, files); + case "file-not-contains": + return checkFileNotContains(rule, files); + case "css-property": + return checkCssProperty(rule, files); + case "nesting": + return checkNesting(rule, files); + case "composite": + return checkComposite(rule, files); + default: + console.warn(`Unimplemented validation rule type: ${(rule as { type: string }).type}`); + return false; + } +} + +export function checkComposite( + rule: { type: "composite"; operator: "and" | "or"; rules: ValidationRule[] }, + files: Record<string, string>, +): boolean { + if (rule.operator === "and") return rule.rules.every((r) => evaluateRule(r, files)); + return rule.rules.some((r) => evaluateRule(r, files)); +} diff --git a/src/lessons/validation/rules/cssProperty.ts b/src/lessons/validation/rules/cssProperty.ts new file mode 100644 index 0000000..aab8e88 --- /dev/null +++ b/src/lessons/validation/rules/cssProperty.ts @@ -0,0 +1,33 @@ +import type { MatchMode } from "../types"; + +function findCssRules(css: string): Array<{ selector: string; body: string }> { + const re = /([^{}]+)\{([^}]*)\}/g; + const results: Array<{ selector: string; body: string }> = []; + let match; + while ((match = re.exec(css)) !== null) { + const selector = (match[1] ?? "").trim(); + const body = match[2] ?? ""; + results.push({ selector, body }); + } + return results; +} + +export function checkCssProperty( + rule: { + type: "css-property"; + selector: string; + file: string; + property: string; + match: MatchMode; + }, + files: Record<string, string>, +): boolean { + const css = files[rule.file]; + if (css === undefined) return false; + const parsed = findCssRules(css); + const matching = parsed.find((r) => r.selector === rule.selector); + if (!matching) return false; + const escaped = rule.property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const propRe = new RegExp(`${escaped}\\s*:`, "i"); + return propRe.test(matching.body); +} diff --git a/src/lessons/validation/rules/elementExists.ts b/src/lessons/validation/rules/elementExists.ts new file mode 100644 index 0000000..9f418c6 --- /dev/null +++ b/src/lessons/validation/rules/elementExists.ts @@ -0,0 +1,9 @@ +export function checkElementExists( + rule: { type: "element-exists"; selector: string; file: string }, + files: Record<string, string>, +): boolean { + const html = files[rule.file]; + if (html === undefined) return false; + const doc = new DOMParser().parseFromString(html, "text/html"); + return doc.querySelector(rule.selector) !== null; +} diff --git a/src/lessons/validation/rules/fileContains.ts b/src/lessons/validation/rules/fileContains.ts new file mode 100644 index 0000000..49de460 --- /dev/null +++ b/src/lessons/validation/rules/fileContains.ts @@ -0,0 +1,17 @@ +export function checkFileContains( + rule: { type: "file-contains"; file: string; text: string }, + files: Record<string, string>, +): boolean { + const content = files[rule.file]; + if (content === undefined) return false; + return content.includes(rule.text); +} + +export function checkFileNotContains( + rule: { type: "file-not-contains"; file: string; text: string }, + files: Record<string, string>, +): boolean { + const content = files[rule.file]; + if (content === undefined) return true; + return !content.includes(rule.text); +} diff --git a/src/lessons/validation/rules/nesting.ts b/src/lessons/validation/rules/nesting.ts new file mode 100644 index 0000000..4c5f260 --- /dev/null +++ b/src/lessons/validation/rules/nesting.ts @@ -0,0 +1,10 @@ +export function checkNesting( + rule: { type: "nesting"; parent: string; child: string; direct?: boolean; file: string }, + files: Record<string, string>, +): boolean { + const html = files[rule.file]; + if (html === undefined) return false; + const doc = new DOMParser().parseFromString(html, "text/html"); + const selector = rule.direct ? `${rule.parent} > ${rule.child}` : `${rule.parent} ${rule.child}`; + return doc.querySelector(selector) !== null; +} diff --git a/src/lessons/validation/rules/rules.test.ts b/src/lessons/validation/rules/rules.test.ts new file mode 100644 index 0000000..8cd7404 --- /dev/null +++ b/src/lessons/validation/rules/rules.test.ts @@ -0,0 +1,185 @@ +// @vitest-environment jsdom +import { describe, expect, test } from "vitest"; + +import { checkElementExists } from "./elementExists"; +import { checkFileContains, checkFileNotContains } from "./fileContains"; +import { checkCssProperty } from "./cssProperty"; +import { checkNesting } from "./nesting"; +import { checkComposite } from "./composite"; + +const HTML = `<!DOCTYPE html><html><head><title>Test

Hello

World

`; +const CSS = `body { background-color: red; font-size: 16px; }\n.title { color: blue; }`; +const FILES: Record = { "/index.html": HTML, "/style.css": CSS }; + +describe("elementExists", () => { + test("passes when selector matches", () => { + expect( + checkElementExists({ type: "element-exists", selector: "h1", file: "/index.html" }, FILES), + ).toBe(true); + }); + test("fails when selector does not match", () => { + expect( + checkElementExists({ type: "element-exists", selector: "h2", file: "/index.html" }, FILES), + ).toBe(false); + }); + test("fails when file is missing", () => { + expect( + checkElementExists({ type: "element-exists", selector: "h1", file: "/missing.html" }, FILES), + ).toBe(false); + }); +}); + +describe("fileContains", () => { + test("passes when text is present", () => { + expect( + checkFileContains( + { type: "file-contains", file: "/style.css", text: "background-color" }, + FILES, + ), + ).toBe(true); + }); + test("fails when text is absent", () => { + expect( + checkFileContains( + { type: "file-contains", file: "/style.css", text: "display: flex" }, + FILES, + ), + ).toBe(false); + }); +}); + +describe("fileNotContains", () => { + test("passes when text is absent", () => { + expect( + checkFileNotContains( + { type: "file-not-contains", file: "/index.html", text: "

" }, + FILES, + ), + ).toBe(true); + }); + test("fails when text is present", () => { + expect( + checkFileNotContains({ type: "file-not-contains", file: "/index.html", text: " { + test("passes when selector has the property", () => { + expect( + checkCssProperty( + { + type: "css-property", + selector: "body", + file: "/style.css", + property: "background-color", + match: "exists", + }, + FILES, + ), + ).toBe(true); + }); + test("fails when property is missing", () => { + expect( + checkCssProperty( + { + type: "css-property", + selector: "body", + file: "/style.css", + property: "display", + match: "exists", + }, + FILES, + ), + ).toBe(false); + }); + test("fails when selector is missing", () => { + expect( + checkCssProperty( + { + type: "css-property", + selector: ".missing", + file: "/style.css", + property: "color", + match: "exists", + }, + FILES, + ), + ).toBe(false); + }); +}); + +describe("nesting", () => { + test("passes when child is inside parent", () => { + expect( + checkNesting({ type: "nesting", parent: "body", child: "h1", file: "/index.html" }, FILES), + ).toBe(true); + }); + test("fails when child is not inside parent", () => { + expect( + checkNesting({ type: "nesting", parent: "h1", child: "p", file: "/index.html" }, FILES), + ).toBe(false); + }); + test("direct child check", () => { + expect( + checkNesting( + { type: "nesting", parent: "body", child: "h1", direct: true, file: "/index.html" }, + FILES, + ), + ).toBe(true); + expect( + checkNesting( + { type: "nesting", parent: "html", child: "h1", direct: true, file: "/index.html" }, + FILES, + ), + ).toBe(false); + }); +}); + +describe("composite", () => { + test("AND passes when all sub-rules pass", () => { + expect( + checkComposite( + { + type: "composite", + operator: "and", + rules: [ + { type: "element-exists", selector: "h1", file: "/index.html" }, + { type: "file-contains", file: "/style.css", text: "body" }, + ], + }, + FILES, + ), + ).toBe(true); + }); + test("AND fails when one sub-rule fails", () => { + expect( + checkComposite( + { + type: "composite", + operator: "and", + rules: [ + { type: "element-exists", selector: "h1", file: "/index.html" }, + { type: "element-exists", selector: "h99", file: "/index.html" }, + ], + }, + FILES, + ), + ).toBe(false); + }); + test("OR passes when any sub-rule passes", () => { + expect( + checkComposite( + { + type: "composite", + operator: "or", + rules: [ + { type: "element-exists", selector: "h99", file: "/index.html" }, + { type: "element-exists", selector: "h1", file: "/index.html" }, + ], + }, + FILES, + ), + ).toBe(true); + }); +}); diff --git a/src/lessons/validation/types.test.ts b/src/lessons/validation/types.test.ts new file mode 100644 index 0000000..e8a8542 --- /dev/null +++ b/src/lessons/validation/types.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "vitest"; +import type { ValidationRule } from "./types"; + +describe("ValidationRule types", () => { + test("element-exists rule is well-typed", () => { + const rule: ValidationRule = { type: "element-exists", selector: "h1", file: "/index.html" }; + expect(rule.type).toBe("element-exists"); + }); + + test("composite rule accepts nested rules", () => { + const rule: ValidationRule = { + type: "composite", + operator: "and", + rules: [ + { type: "element-exists", selector: "h1", file: "/index.html" }, + { type: "file-contains", file: "/index.html", text: "

" }, + ], + }; + expect(rule.rules).toHaveLength(2); + }); +}); diff --git a/src/lessons/validation/types.ts b/src/lessons/validation/types.ts new file mode 100644 index 0000000..09912c2 --- /dev/null +++ b/src/lessons/validation/types.ts @@ -0,0 +1,47 @@ +export type MatchMode = + | "exact" + | "contains" + | "starts-with" + | "ends-with" + | "regex" + | "not-empty" + | "exists" + | "gte" + | "lte"; + +export type ValidationRule = + | { type: "element-exists"; selector: string; file: string } + | { type: "element-count"; selector: string; file: string; min?: number; max?: number } + | { type: "element-text"; selector: string; file: string; text: string; match: MatchMode } + | { type: "attribute-exists"; selector: string; file: string; attribute: string } + | { + type: "attribute-value"; + selector: string; + file: string; + attribute: string; + value?: string; + match: MatchMode; + } + | { type: "attribute-count"; selector: string; file: string; minAttributes: number } + | { type: "css-property"; selector: string; file: string; property: string; match: MatchMode } + | { + type: "css-property-value"; + selector: string; + file: string; + property: string; + value: string; + match: MatchMode; + } + | { type: "file-contains"; file: string; text: string } + | { type: "file-not-contains"; file: string; text: string } + | { type: "file-regex"; file: string; pattern: string } + | { type: "nesting"; parent: string; child: string; direct?: boolean; file: string } + | { type: "element-order"; selectors: string[]; within: string; file: string } + | { type: "sibling"; first: string; then: string; file: string } + | { type: "indent-style"; file: string; style: "spaces" | "tabs"; size?: number } + | { type: "composite"; operator: "and" | "or"; rules: ValidationRule[] }; + +export interface CheckpointResult { + checkpointId: string; + passed: boolean; +} diff --git a/src/lessons/validation/validate.test.ts b/src/lessons/validation/validate.test.ts new file mode 100644 index 0000000..09ac5e9 --- /dev/null +++ b/src/lessons/validation/validate.test.ts @@ -0,0 +1,42 @@ +// @vitest-environment jsdom +import { describe, expect, test } from "vitest"; +import { validateCheckpoints } from "./validate"; + +const FILES = { + "/index.html": "

Hi

", + "/style.css": "body { color: red; }", +}; + +const CHECKPOINTS = [ + { + id: "has-head", + label: "Add head", + rule: { type: "element-exists" as const, selector: "head", file: "/index.html" }, + }, + { + id: "has-h2", + label: "Add h2", + rule: { type: "element-exists" as const, selector: "h2", file: "/index.html" }, + }, + { + id: "has-color", + label: "Set color", + rule: { + type: "css-property" as const, + selector: "body", + file: "/style.css", + property: "color", + match: "exists" as const, + }, + }, +]; + +describe("validateCheckpoints", () => { + test("returns results for each checkpoint", () => { + const results = validateCheckpoints(CHECKPOINTS, FILES); + expect(results).toHaveLength(3); + expect(results.find((r) => r.checkpointId === "has-head")?.passed).toBe(true); + expect(results.find((r) => r.checkpointId === "has-h2")?.passed).toBe(false); + expect(results.find((r) => r.checkpointId === "has-color")?.passed).toBe(true); + }); +}); diff --git a/src/lessons/validation/validate.ts b/src/lessons/validation/validate.ts new file mode 100644 index 0000000..0f99101 --- /dev/null +++ b/src/lessons/validation/validate.ts @@ -0,0 +1,18 @@ +import type { CheckpointResult, ValidationRule } from "./types"; +import { evaluateRule } from "./rules/composite"; + +interface CheckpointDef { + id: string; + label: string; + rule: ValidationRule; +} + +export function validateCheckpoints( + checkpoints: CheckpointDef[], + files: Record, +): CheckpointResult[] { + return checkpoints.map((cp) => ({ + checkpointId: cp.id, + passed: evaluateRule(cp.rule, files), + })); +} diff --git a/src/state/ideStore.ts b/src/state/ideStore.ts index 92f10ff..88247c5 100644 --- a/src/state/ideStore.ts +++ b/src/state/ideStore.ts @@ -14,7 +14,7 @@ export interface EditorCursor { */ export type PreviewDevice = "mobile" | "desktop"; -export type View = "home" | "ide"; +export type View = "home" | "ide" | "lesson"; interface IdeState { openFiles: string[]; @@ -94,7 +94,7 @@ export const useIdeStore = create((set) => ({ const active = activeFile && cleaned.includes(activeFile) ? activeFile : (cleaned[0] ?? null); const next: Partial = { openFiles: cleaned, activeFile: active }; if (typeof dockCollapsed === "boolean") next.dockCollapsed = dockCollapsed; - if (view === "ide" || view === "home") next.view = view; + if (view === "ide" || view === "home" || view === "lesson") next.view = view; return next; }), })); diff --git a/src/styles/global.css b/src/styles/global.css index 2ab30cf..be734e9 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1099,7 +1099,7 @@ button.brand { } /* ============================================================ - Lesson dock (UI shell — mocked content until the lesson engine lands) + Lesson dock ============================================================ */ .dock { @@ -1107,11 +1107,12 @@ button.brand { border-top: 1px solid var(--line-soft); display: flex; flex-direction: column; + min-height: 0; max-height: 44vh; - transition: max-height 0.2s ease; overflow: hidden; + transition: max-height 0.2s; } -.dock--collapsed { +.dock.collapsed { max-height: 40px; } @@ -1119,25 +1120,23 @@ button.brand { display: flex; align-items: center; gap: 10px; + width: 100%; height: 40px; padding: 0 14px; - width: 100%; - color: var(--fg-0); - font: inherit; - text-align: left; - flex: none; cursor: pointer; + flex: none; background: transparent; border: 0; -} -.dock-head:hover { - background: var(--bg-1); + color: var(--fg-0); + font: inherit; + text-align: left; } .dock-head .chev { color: var(--fg-3); - transition: transform 0.15s ease; + transition: transform 0.15s; + font-size: 0.85em; } -.dock--collapsed .dock-head .chev { +.dock.collapsed .dock-head .chev { transform: rotate(-90deg); } @@ -1166,10 +1165,10 @@ button.brand { } .progress { - margin-left: auto; display: flex; align-items: center; gap: 10px; + margin-left: auto; color: var(--fg-2); font-size: 0.88em; } @@ -1189,7 +1188,7 @@ button.brand { .dock-body { padding: 4px 14px 14px; overflow-y: auto; - flex: 1 1 auto; + flex: 1; min-height: 0; display: grid; grid-template-columns: 1fr 1.2fr; @@ -1201,7 +1200,6 @@ button.brand { font-size: 1.1em; font-weight: 600; letter-spacing: -0.01em; - color: var(--fg-0); } .lesson-text p { margin: 0 0 10px; @@ -1262,22 +1260,22 @@ button.brand { font-size: 0.75em; flex: none; } -.cp--done .tick { +.cp.done .tick { background: var(--accent); border-color: var(--accent); color: var(--bg-0); } -.cp--active { +.cp.active { background: var(--accent-soft); border-color: var(--accent-dim); } -.cp--active .tick { +.cp.active .tick { border-color: var(--accent); } .cp .label { color: var(--fg-0); } -.cp--done .label { +.cp.done .label { color: var(--fg-2); text-decoration: line-through; text-decoration-color: var(--fg-3); @@ -1287,7 +1285,7 @@ button.brand { font-size: 0.78em; color: var(--fg-3); } -.cp .meta--live { +.cp .meta.live { color: var(--accent); } @@ -1483,6 +1481,12 @@ button.brand { background: var(--accent); border-radius: 2px; } +.home-nav-item__counter { + margin-left: auto; + color: var(--fg-3); + font-family: var(--font-mono); + font-size: 11px; +} .home-rail-foot { margin-top: auto; @@ -1927,6 +1931,99 @@ button.brand { opacity: 1; } +/* Lesson & Challenge cards */ +.lesson-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.lesson-card { + text-align: left; + padding: 14px; + background: var(--bg-1); + border: 1px solid var(--line-soft); + border-radius: var(--radius); + cursor: pointer; + transition: + border-color 0.12s, + background 0.12s; + display: flex; + flex-direction: column; + gap: 6px; + font: inherit; + color: inherit; + width: 100%; +} +.lesson-card:hover { + border-color: var(--line); + background: var(--bg-2); +} +.lesson-card:focus-visible { + outline: none; + border-color: var(--accent); +} + +.lesson-card--in-progress { + border-color: oklch(0.55 0.09 75 / 0.5); +} + +.lesson-card__header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.lesson-card__badge { + font-size: 10px; + font-weight: 500; + padding: 2px 8px; + border-radius: 4px; +} +.lesson-card__badge--done { + background: oklch(0.35 0.1 155 / 0.35); + color: var(--ok); +} +.lesson-card__badge--active { + background: oklch(0.35 0.1 75 / 0.35); + color: var(--accent); +} +.lesson-card__badge--todo { + color: var(--fg-3); +} + +.lesson-card__time { + color: var(--fg-3); + font-size: 11px; +} + +.lesson-card__title { + font-size: 14px; + font-weight: 500; + color: var(--fg-0); +} + +.lesson-card__desc { + font-size: 12px; + color: var(--fg-2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.lesson-card__progress { + height: 3px; + background: var(--line-soft); + border-radius: 2px; + margin-top: 4px; +} +.lesson-card__progress-fill { + height: 100%; + background: var(--accent); + border-radius: 2px; + transition: width 0.3s ease; +} + /* View transition — Home ↔ IDE */ @keyframes view-in { from {