feat: add HTML lessons mode and side-by-side comparison UI
- Add HTML mode support with new validation types (element_exists, element_count, attribute_value, element_text, parent_child, sibling) - Create 3 HTML lesson modules: Elements, Forms Basic, Forms Validation - Implement side-by-side preview comparison (Your Output vs Expected) - Add merge animation with "Perfect Match!" overlay on validation success - Render expected output from solutionCode field in lesson JSON - Update schema to support HTML mode and solutionCode - Reorder modules: HTML first, then CSS, then Tailwind - Update tests for new functionality
This commit is contained in:
49
CLAUDE.md
Normal file
49
CLAUDE.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Code Crispies is an interactive CSS/Tailwind learning platform built with pure JavaScript (ES Modules) and Vite. Users complete lessons by writing CSS or Tailwind code that passes validation rules.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm start # Start dev server at http://localhost:1312
|
||||
npm run build # Production build to dist/
|
||||
npm run test # Run tests once
|
||||
npm run test.watch # Run tests in watch mode
|
||||
npm run test.coverage # Run tests with coverage report
|
||||
npm run format # Format source files with Prettier
|
||||
npm run format.lessons # Format lesson JSON files
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
- **LessonEngine** (`src/impl/LessonEngine.js`): Single source of truth for lesson state, progress tracking, and code validation. Manages user code application to preview iframes and persists progress to localStorage.
|
||||
|
||||
- **Validator** (`src/helpers/validator.js`): Validates user code against lesson requirements. Supports validation types: `contains`, `contains_class`, `not_contains`, `regex`, `property_value`, `syntax`, `custom`.
|
||||
|
||||
- **Lesson Config** (`src/config/lessons.js`): Loads and validates lesson modules from JSON. Modules are imported statically and stored in `moduleStore`.
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. Lesson JSON files define modules with lessons, each containing `previewHTML`, `validations`, and other metadata
|
||||
2. `LessonEngine` loads modules and tracks user progress per lesson
|
||||
3. User code is applied to an iframe preview and validated against lesson rules
|
||||
4. Progress is persisted to `localStorage` under `codeCrispies.progress` and `codeCrispies.userCode`
|
||||
|
||||
### Lesson Structure
|
||||
|
||||
Lessons are JSON files in `lessons/` following the schema in `schemas/code-crispies-module-schema.json`. Each module has:
|
||||
- `mode`: "css" or "tailwind"
|
||||
- `difficulty`: "beginner", "intermediate", or "advanced"
|
||||
- `lessons[]`: Array of lessons with validations
|
||||
|
||||
For Tailwind mode, user classes are injected via `{{USER_CLASSES}}` placeholder in `previewHTML`.
|
||||
|
||||
### Testing
|
||||
|
||||
Tests use Vitest with jsdom environment. Setup in `tests/setup.js` includes DOM testing library matchers. Test files are in `tests/unit/`.
|
||||
97
lessons/20-html-elements.json
Normal file
97
lessons/20-html-elements.json
Normal file
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"$schema": "../schemas/code-crispies-module-schema.json",
|
||||
"id": "html-elements",
|
||||
"title": "HTML Elements: Block vs Inline",
|
||||
"description": "Understanding the fundamental difference between container (block) and inline elements",
|
||||
"mode": "html",
|
||||
"difficulty": "beginner",
|
||||
"lessons": [
|
||||
{
|
||||
"id": "block-vs-inline-intro",
|
||||
"title": "Block vs Inline Elements",
|
||||
"description": "HTML elements fall into two main categories:<br><br><strong>Block elements</strong> (containers) start on a new line and take full width. Examples: <kbd><div></kbd>, <kbd><p></kbd>, <kbd><h1></kbd>, <kbd><section></kbd><br><br><strong>Inline elements</strong> flow within text and only take needed width. Examples: <kbd><span></kbd>, <kbd><a></kbd>, <kbd><strong></kbd>, <kbd><em></kbd>",
|
||||
"task": "Create a paragraph with a <kbd><strong></kbd> word inside it. Notice how the paragraph is a block element (takes full width) while strong is inline (flows with text).",
|
||||
"previewHTML": "",
|
||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 20px; } p { background: #e3f2fd; padding: 10px; } strong { background: #ffecb3; }",
|
||||
"sandboxCSS": "",
|
||||
"initialCode": "<p>This is a paragraph with an important word.</p>",
|
||||
"solutionCode": "<p>This is a paragraph with a <strong>important</strong> word.</p>",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "element_exists",
|
||||
"value": "p",
|
||||
"message": "Add a <p> paragraph element"
|
||||
},
|
||||
{
|
||||
"type": "parent_child",
|
||||
"value": { "parent": "p", "child": "strong" },
|
||||
"message": "Place a <strong> element inside your paragraph"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "semantic-containers",
|
||||
"title": "Semantic Container Elements",
|
||||
"description": "Modern HTML uses semantic containers that describe their content:<br><br><kbd><header></kbd> - Page or section header<br><kbd><nav></kbd> - Navigation links<br><kbd><main></kbd> - Main content area<br><kbd><section></kbd> - Thematic grouping<br><kbd><article></kbd> - Self-contained content<br><kbd><footer></kbd> - Page or section footer",
|
||||
"task": "Create a basic page structure with <kbd><header></kbd>, <kbd><main></kbd>, and <kbd><footer></kbd> elements. Add a heading in the header.",
|
||||
"previewHTML": "",
|
||||
"previewBaseCSS": "body { font-family: system-ui; margin: 0; } header { background: #1976d2; color: white; padding: 15px; } main { padding: 20px; min-height: 100px; } footer { background: #424242; color: white; padding: 10px; text-align: center; }",
|
||||
"sandboxCSS": "",
|
||||
"initialCode": "<!-- Create your page structure here -->",
|
||||
"solutionCode": "<header>\n <h1>My Website</h1>\n</header>\n<main>\n <p>Welcome to my site!</p>\n</main>\n<footer>\n <p>Copyright 2025</p>\n</footer>",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "element_exists",
|
||||
"value": "header",
|
||||
"message": "Add a <header> element"
|
||||
},
|
||||
{
|
||||
"type": "element_exists",
|
||||
"value": "main",
|
||||
"message": "Add a <main> element"
|
||||
},
|
||||
{
|
||||
"type": "element_exists",
|
||||
"value": "footer",
|
||||
"message": "Add a <footer> element"
|
||||
},
|
||||
{
|
||||
"type": "parent_child",
|
||||
"value": { "parent": "header", "child": "h1" },
|
||||
"message": "Add an <h1> heading inside your header"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "div-vs-span",
|
||||
"title": "Generic Containers: div and span",
|
||||
"description": "When you need a container without semantic meaning:<br><br><kbd><div></kbd> - Generic block container (for layout/grouping)<br><kbd><span></kbd> - Generic inline container (for styling text portions)<br><br>Use semantic elements when possible, div/span when no semantic element fits.",
|
||||
"task": "Wrap the word 'highlighted' in a <kbd><span></kbd> to style it differently. Wrap the whole quote in a <kbd><div></kbd>.",
|
||||
"previewHTML": "",
|
||||
"previewBaseCSS": "body { font-family: Georgia, serif; padding: 20px; } div { background: #f5f5f5; padding: 15px; border-left: 4px solid #1976d2; } span { background: #fff59d; padding: 2px 4px; }",
|
||||
"sandboxCSS": "",
|
||||
"initialCode": "The most highlighted moment was unforgettable.",
|
||||
"solutionCode": "<div>The most <span>highlighted</span> moment was unforgettable.</div>",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "element_exists",
|
||||
"value": "div",
|
||||
"message": "Wrap everything in a <div> element"
|
||||
},
|
||||
{
|
||||
"type": "element_exists",
|
||||
"value": "span",
|
||||
"message": "Add a <span> around the word 'highlighted'"
|
||||
},
|
||||
{
|
||||
"type": "element_text",
|
||||
"value": { "selector": "span", "text": "highlighted" },
|
||||
"message": "The <span> should contain the word 'highlighted'"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
102
lessons/21-html-forms-basic.json
Normal file
102
lessons/21-html-forms-basic.json
Normal file
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"$schema": "../schemas/code-crispies-module-schema.json",
|
||||
"id": "html-forms-basic",
|
||||
"title": "HTML Forms: Basic Inputs",
|
||||
"description": "Learn to create forms with various input types",
|
||||
"mode": "html",
|
||||
"difficulty": "beginner",
|
||||
"lessons": [
|
||||
{
|
||||
"id": "form-structure",
|
||||
"title": "Form Structure",
|
||||
"description": "Every form needs a <kbd><form></kbd> wrapper. Inside, use <kbd><label></kbd> to describe inputs and <kbd><input></kbd> for user data entry.<br><br>The <kbd>for</kbd> attribute on labels should match the <kbd>id</kbd> on inputs for accessibility.",
|
||||
"task": "Create a form with a text input for 'Name'. Include a label connected to the input using for/id attributes.",
|
||||
"previewHTML": "",
|
||||
"previewBaseCSS": "body { font-family: system-ui; padding: 20px; } form { max-width: 300px; } label { display: block; margin-bottom: 5px; font-weight: 500; } input { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }",
|
||||
"sandboxCSS": "",
|
||||
"initialCode": "<!-- Create your form here -->",
|
||||
"solutionCode": "<form>\n <label for=\"name\">Name:</label>\n <input type=\"text\" id=\"name\" name=\"name\">\n</form>",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "element_exists",
|
||||
"value": "form",
|
||||
"message": "Wrap everything in a <form> element"
|
||||
},
|
||||
{
|
||||
"type": "element_exists",
|
||||
"value": "label",
|
||||
"message": "Add a <label> for your input"
|
||||
},
|
||||
{
|
||||
"type": "element_exists",
|
||||
"value": "input",
|
||||
"message": "Add an <input> element"
|
||||
},
|
||||
{
|
||||
"type": "attribute_value",
|
||||
"value": { "selector": "label", "attr": "for", "value": null },
|
||||
"message": "Add a 'for' attribute to your label"
|
||||
},
|
||||
{
|
||||
"type": "attribute_value",
|
||||
"value": { "selector": "input", "attr": "id", "value": null },
|
||||
"message": "Add an 'id' attribute to your input"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "input-types",
|
||||
"title": "Input Types",
|
||||
"description": "Different input types provide appropriate keyboards and validation:<br><br><kbd>type=\"text\"</kbd> - General text<br><kbd>type=\"email\"</kbd> - Email with @ validation<br><kbd>type=\"password\"</kbd> - Hidden characters<br><kbd>type=\"number\"</kbd> - Numeric keyboard<br><kbd>type=\"tel\"</kbd> - Phone keyboard",
|
||||
"task": "Create a login form with an email input and a password input, each with proper labels.",
|
||||
"previewHTML": "",
|
||||
"previewBaseCSS": "body { font-family: system-ui; padding: 20px; } form { max-width: 300px; } .form-group { margin-bottom: 15px; } label { display: block; margin-bottom: 5px; } input { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }",
|
||||
"sandboxCSS": "",
|
||||
"initialCode": "<form>\n <!-- Add email and password inputs -->\n</form>",
|
||||
"solutionCode": "<form>\n <div class=\"form-group\">\n <label for=\"email\">Email:</label>\n <input type=\"email\" id=\"email\" name=\"email\">\n </div>\n <div class=\"form-group\">\n <label for=\"password\">Password:</label>\n <input type=\"password\" id=\"password\" name=\"password\">\n </div>\n</form>",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "element_exists",
|
||||
"value": "input[type='email']",
|
||||
"message": "Add an input with type=\"email\""
|
||||
},
|
||||
{
|
||||
"type": "element_exists",
|
||||
"value": "input[type='password']",
|
||||
"message": "Add an input with type=\"password\""
|
||||
},
|
||||
{
|
||||
"type": "element_count",
|
||||
"value": { "selector": "label", "min": 2 },
|
||||
"message": "Add labels for both inputs"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "submit-button",
|
||||
"title": "Submit Button",
|
||||
"description": "Forms need a way to submit data. Use:<br><br><kbd><button type=\"submit\"></kbd> - Preferred, flexible content<br><kbd><input type=\"submit\"></kbd> - Simple text-only button<br><br>The button text should be action-oriented (e.g., 'Sign In', 'Register', 'Send').",
|
||||
"task": "Add a submit button to the form with the text 'Sign In'.",
|
||||
"previewHTML": "",
|
||||
"previewBaseCSS": "body { font-family: system-ui; padding: 20px; } form { max-width: 300px; } .form-group { margin-bottom: 15px; } label { display: block; margin-bottom: 5px; } input { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; } button { width: 100%; padding: 10px; background: #1976d2; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; } button:hover { background: #1565c0; }",
|
||||
"sandboxCSS": "",
|
||||
"initialCode": "<form>\n <div class=\"form-group\">\n <label for=\"email\">Email:</label>\n <input type=\"email\" id=\"email\">\n </div>\n <div class=\"form-group\">\n <label for=\"password\">Password:</label>\n <input type=\"password\" id=\"password\">\n </div>\n <!-- Add submit button -->\n</form>",
|
||||
"solutionCode": "<form>\n <div class=\"form-group\">\n <label for=\"email\">Email:</label>\n <input type=\"email\" id=\"email\">\n </div>\n <div class=\"form-group\">\n <label for=\"password\">Password:</label>\n <input type=\"password\" id=\"password\">\n </div>\n <button type=\"submit\">Sign In</button>\n</form>",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "element_exists",
|
||||
"value": "button[type='submit'], input[type='submit']",
|
||||
"message": "Add a submit button to your form"
|
||||
},
|
||||
{
|
||||
"type": "element_text",
|
||||
"value": { "selector": "button", "text": "Sign In" },
|
||||
"message": "The button should say 'Sign In'"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
112
lessons/22-html-forms-validation.json
Normal file
112
lessons/22-html-forms-validation.json
Normal file
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"$schema": "../schemas/code-crispies-module-schema.json",
|
||||
"id": "html-forms-validation",
|
||||
"title": "HTML Forms: Validation",
|
||||
"description": "Learn HTML5 built-in form validation attributes",
|
||||
"mode": "html",
|
||||
"difficulty": "intermediate",
|
||||
"lessons": [
|
||||
{
|
||||
"id": "required-fields",
|
||||
"title": "Required Fields",
|
||||
"description": "The <kbd>required</kbd> attribute prevents form submission if the field is empty.<br><br>Add it to any input that must be filled:<br><kbd><input type=\"text\" required></kbd><br><br>The browser shows a validation message automatically.",
|
||||
"task": "Make both the name and email fields required.",
|
||||
"previewHTML": "",
|
||||
"previewBaseCSS": "body { font-family: system-ui; padding: 20px; } form { max-width: 350px; } .form-group { margin-bottom: 15px; } label { display: block; margin-bottom: 5px; } label .required { color: #d32f2f; } input { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; } input:invalid { border-color: #d32f2f; } button { padding: 10px 20px; background: #1976d2; color: white; border: none; border-radius: 4px; cursor: pointer; }",
|
||||
"sandboxCSS": "",
|
||||
"initialCode": "<form>\n <div class=\"form-group\">\n <label for=\"name\">Name: <span class=\"required\">*</span></label>\n <input type=\"text\" id=\"name\" name=\"name\">\n </div>\n <div class=\"form-group\">\n <label for=\"email\">Email: <span class=\"required\">*</span></label>\n <input type=\"email\" id=\"email\" name=\"email\">\n </div>\n <button type=\"submit\">Submit</button>\n</form>",
|
||||
"solutionCode": "<form>\n <div class=\"form-group\">\n <label for=\"name\">Name: <span class=\"required\">*</span></label>\n <input type=\"text\" id=\"name\" name=\"name\" required>\n </div>\n <div class=\"form-group\">\n <label for=\"email\">Email: <span class=\"required\">*</span></label>\n <input type=\"email\" id=\"email\" name=\"email\" required>\n </div>\n <button type=\"submit\">Submit</button>\n</form>",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "attribute_value",
|
||||
"value": { "selector": "input[name='name']", "attr": "required", "value": true },
|
||||
"message": "Add the 'required' attribute to the name input"
|
||||
},
|
||||
{
|
||||
"type": "attribute_value",
|
||||
"value": { "selector": "input[name='email']", "attr": "required", "value": true },
|
||||
"message": "Add the 'required' attribute to the email input"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "input-constraints",
|
||||
"title": "Input Constraints",
|
||||
"description": "Control what users can enter:<br><br><kbd>minlength</kbd> / <kbd>maxlength</kbd> - Text length limits<br><kbd>min</kbd> / <kbd>max</kbd> - Number range<br><kbd>pattern</kbd> - Regex pattern matching<br><kbd>placeholder</kbd> - Hint text (not a label!)",
|
||||
"task": "Add validation: password must be 8-20 characters. Add a helpful placeholder.",
|
||||
"previewHTML": "",
|
||||
"previewBaseCSS": "body { font-family: system-ui; padding: 20px; } form { max-width: 350px; } .form-group { margin-bottom: 15px; } label { display: block; margin-bottom: 5px; } input { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; } input:invalid:not(:placeholder-shown) { border-color: #d32f2f; } .hint { font-size: 12px; color: #666; margin-top: 4px; } button { padding: 10px 20px; background: #1976d2; color: white; border: none; border-radius: 4px; cursor: pointer; }",
|
||||
"sandboxCSS": "",
|
||||
"initialCode": "<form>\n <div class=\"form-group\">\n <label for=\"password\">Password:</label>\n <input type=\"password\" id=\"password\" name=\"password\" required>\n <div class=\"hint\">Must be 8-20 characters</div>\n </div>\n <button type=\"submit\">Create Account</button>\n</form>",
|
||||
"solutionCode": "<form>\n <div class=\"form-group\">\n <label for=\"password\">Password:</label>\n <input type=\"password\" id=\"password\" name=\"password\" required minlength=\"8\" maxlength=\"20\" placeholder=\"Enter password\">\n <div class=\"hint\">Must be 8-20 characters</div>\n </div>\n <button type=\"submit\">Create Account</button>\n</form>",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "attribute_value",
|
||||
"value": { "selector": "input[type='password']", "attr": "minlength", "value": "8" },
|
||||
"message": "Add minlength=\"8\" to the password input"
|
||||
},
|
||||
{
|
||||
"type": "attribute_value",
|
||||
"value": { "selector": "input[type='password']", "attr": "maxlength", "value": "20" },
|
||||
"message": "Add maxlength=\"20\" to the password input"
|
||||
},
|
||||
{
|
||||
"type": "attribute_value",
|
||||
"value": { "selector": "input[type='password']", "attr": "placeholder", "value": null },
|
||||
"message": "Add a placeholder to hint what to enter"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "complete-registration",
|
||||
"title": "Complete Registration Form",
|
||||
"description": "Build a complete registration form with all validation concepts:<br><br>- Required fields marked with *<br>- Email validation (use type=\"email\")<br>- Password with length constraints<br>- Terms checkbox (required)<br>- Submit button",
|
||||
"task": "Complete the registration form. Add required attributes, proper input types, and validation constraints.",
|
||||
"previewHTML": "",
|
||||
"previewBaseCSS": "body { font-family: system-ui; padding: 20px; } form { max-width: 400px; background: #f5f5f5; padding: 25px; border-radius: 8px; } h2 { margin-top: 0; margin-bottom: 20px; } .form-group { margin-bottom: 18px; } label { display: block; margin-bottom: 5px; font-weight: 500; } .required { color: #d32f2f; } input[type='text'], input[type='email'], input[type='password'] { width: 100%; padding: 10px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; font-size: 14px; } input:focus { outline: 2px solid #1976d2; border-color: transparent; } .checkbox-group { display: flex; align-items: flex-start; gap: 8px; } .checkbox-group input { width: auto; margin-top: 3px; } .checkbox-group label { margin: 0; font-weight: normal; } button { width: 100%; padding: 12px; background: #1976d2; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; font-weight: 500; } button:hover { background: #1565c0; }",
|
||||
"sandboxCSS": "",
|
||||
"initialCode": "<form>\n <h2>Create Account</h2>\n \n <div class=\"form-group\">\n <label for=\"fullname\">Full Name <span class=\"required\">*</span></label>\n <input type=\"text\" id=\"fullname\" name=\"fullname\">\n </div>\n \n <div class=\"form-group\">\n <label for=\"email\">Email <span class=\"required\">*</span></label>\n <input id=\"email\" name=\"email\">\n </div>\n \n <div class=\"form-group\">\n <label for=\"password\">Password <span class=\"required\">*</span></label>\n <input id=\"password\" name=\"password\">\n </div>\n \n <div class=\"form-group checkbox-group\">\n <input type=\"checkbox\" id=\"terms\" name=\"terms\">\n <label for=\"terms\">I agree to the Terms of Service <span class=\"required\">*</span></label>\n </div>\n \n <button type=\"submit\">Register</button>\n</form>",
|
||||
"solutionCode": "<form>\n <h2>Create Account</h2>\n \n <div class=\"form-group\">\n <label for=\"fullname\">Full Name <span class=\"required\">*</span></label>\n <input type=\"text\" id=\"fullname\" name=\"fullname\" required>\n </div>\n \n <div class=\"form-group\">\n <label for=\"email\">Email <span class=\"required\">*</span></label>\n <input type=\"email\" id=\"email\" name=\"email\" required>\n </div>\n \n <div class=\"form-group\">\n <label for=\"password\">Password <span class=\"required\">*</span></label>\n <input type=\"password\" id=\"password\" name=\"password\" required minlength=\"8\">\n </div>\n \n <div class=\"form-group checkbox-group\">\n <input type=\"checkbox\" id=\"terms\" name=\"terms\" required>\n <label for=\"terms\">I agree to the Terms of Service <span class=\"required\">*</span></label>\n </div>\n \n <button type=\"submit\">Register</button>\n</form>",
|
||||
"previewContainer": "preview-area",
|
||||
"validations": [
|
||||
{
|
||||
"type": "attribute_value",
|
||||
"value": { "selector": "#fullname", "attr": "required", "value": true },
|
||||
"message": "Make the full name field required"
|
||||
},
|
||||
{
|
||||
"type": "attribute_value",
|
||||
"value": { "selector": "#email", "attr": "type", "value": "email" },
|
||||
"message": "Set the email input type to 'email'"
|
||||
},
|
||||
{
|
||||
"type": "attribute_value",
|
||||
"value": { "selector": "#email", "attr": "required", "value": true },
|
||||
"message": "Make the email field required"
|
||||
},
|
||||
{
|
||||
"type": "attribute_value",
|
||||
"value": { "selector": "#password", "attr": "type", "value": "password" },
|
||||
"message": "Set the password input type to 'password'"
|
||||
},
|
||||
{
|
||||
"type": "attribute_value",
|
||||
"value": { "selector": "#password", "attr": "required", "value": true },
|
||||
"message": "Make the password field required"
|
||||
},
|
||||
{
|
||||
"type": "attribute_value",
|
||||
"value": { "selector": "#password", "attr": "minlength", "value": "8" },
|
||||
"message": "Add minlength=\"8\" to password"
|
||||
},
|
||||
{
|
||||
"type": "attribute_value",
|
||||
"value": { "selector": "#terms", "attr": "required", "value": true },
|
||||
"message": "Make the terms checkbox required"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -19,8 +19,8 @@
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["css", "tailwind"],
|
||||
"description": "Whether this module teaches CSS or Tailwind"
|
||||
"enum": ["css", "tailwind", "html"],
|
||||
"description": "Whether this module teaches CSS, Tailwind, or HTML"
|
||||
},
|
||||
"difficulty": {
|
||||
"type": "string",
|
||||
@@ -60,7 +60,7 @@
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["css", "tailwind"],
|
||||
"enum": ["css", "tailwind", "html"],
|
||||
"description": "Override module mode for individual lessons"
|
||||
},
|
||||
"tailwindConfig": {
|
||||
@@ -91,6 +91,10 @@
|
||||
"type": "string",
|
||||
"description": "Solution code for the lesson, if applicable"
|
||||
},
|
||||
"solutionCode": {
|
||||
"type": "string",
|
||||
"description": "Expected correct code used to render the expected preview for comparison"
|
||||
},
|
||||
"previewContainer": {
|
||||
"type": "string",
|
||||
"description": "ID of the container element for the preview"
|
||||
@@ -105,26 +109,83 @@
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["contains", "contains_class", "not_contains", "regex", "property_value", "syntax", "custom"],
|
||||
"enum": [
|
||||
"contains",
|
||||
"contains_class",
|
||||
"contains_pattern",
|
||||
"not_contains",
|
||||
"regex",
|
||||
"property_value",
|
||||
"syntax",
|
||||
"custom",
|
||||
"element_exists",
|
||||
"element_count",
|
||||
"attribute_value",
|
||||
"element_text",
|
||||
"parent_child",
|
||||
"sibling"
|
||||
],
|
||||
"description": "Type of validation to perform"
|
||||
},
|
||||
"value": {
|
||||
"description": "Value to check against, format depends on validation type",
|
||||
"description": "Value to check against, format depends on validation type. String for simple checks, object for complex validations.",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["property", "expected"],
|
||||
"description": "Object format for property_value, element_count, attribute_value, element_text, parent_child validations",
|
||||
"properties": {
|
||||
"property": {
|
||||
"type": "string",
|
||||
"description": "CSS property name to validate"
|
||||
"description": "CSS property name (for property_value)"
|
||||
},
|
||||
"expected": {
|
||||
"type": "string",
|
||||
"description": "Expected value for the CSS property"
|
||||
"description": "Expected value (for property_value)"
|
||||
},
|
||||
"selector": {
|
||||
"type": "string",
|
||||
"description": "CSS selector to target element (for HTML validations)"
|
||||
},
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"description": "Expected count of elements (for element_count)"
|
||||
},
|
||||
"min": {
|
||||
"type": "integer",
|
||||
"description": "Minimum count of elements (for element_count)"
|
||||
},
|
||||
"attr": {
|
||||
"type": "string",
|
||||
"description": "Attribute name to check (for attribute_value)"
|
||||
},
|
||||
"value": {
|
||||
"description": "Expected attribute value (for attribute_value). Use true to check existence only."
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Expected text content (for element_text)"
|
||||
},
|
||||
"parent": {
|
||||
"type": "string",
|
||||
"description": "Parent selector (for parent_child)"
|
||||
},
|
||||
"child": {
|
||||
"type": "string",
|
||||
"description": "Child selector (for parent_child)"
|
||||
},
|
||||
"first": {
|
||||
"type": "string",
|
||||
"description": "First sibling selector (for sibling)"
|
||||
},
|
||||
"then": {
|
||||
"type": "string",
|
||||
"description": "Following sibling selector (for sibling)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
34
src/app.js
34
src/app.js
@@ -181,13 +181,24 @@ function updateEditorForMode(mode) {
|
||||
const codeInput = elements.codeInput;
|
||||
const editorLabel = document.querySelector(".editor-label");
|
||||
|
||||
if (mode === "tailwind") {
|
||||
codeInput.placeholder = "Enter Tailwind classes (e.g., bg-blue-500 text-white p-4)";
|
||||
if (editorLabel) editorLabel.textContent = "Tailwind Classes:";
|
||||
} else {
|
||||
codeInput.placeholder = "Enter your CSS code here...";
|
||||
if (editorLabel) editorLabel.textContent = "CSS Code:";
|
||||
}
|
||||
const modeConfig = {
|
||||
html: {
|
||||
placeholder: "Write your HTML here (e.g., <p>Hello World</p>)",
|
||||
label: "HTML Editor"
|
||||
},
|
||||
tailwind: {
|
||||
placeholder: "Enter Tailwind classes (e.g., bg-blue-500 text-white p-4)",
|
||||
label: "Tailwind Classes"
|
||||
},
|
||||
css: {
|
||||
placeholder: "Enter your CSS code here...",
|
||||
label: "CSS Editor"
|
||||
}
|
||||
};
|
||||
|
||||
const config = modeConfig[mode] || modeConfig.css;
|
||||
codeInput.placeholder = config.placeholder;
|
||||
if (editorLabel) editorLabel.textContent = config.label;
|
||||
}
|
||||
|
||||
// Configure editor layout based on display type
|
||||
@@ -266,6 +277,9 @@ function loadCurrentLesson() {
|
||||
// Focus on the code editor by default
|
||||
elements.codeInput.focus();
|
||||
|
||||
// Render the expected/solution preview for comparison
|
||||
lessonEngine.renderExpectedPreview();
|
||||
|
||||
// Track live changes and update preview when the user pauses typing
|
||||
setupLivePreview();
|
||||
}
|
||||
@@ -379,6 +393,9 @@ function runCode() {
|
||||
elements.nextBtn.classList.add("success");
|
||||
elements.taskInstruction.classList.add("success-instruction");
|
||||
|
||||
// Show merge animation for side-by-side comparison
|
||||
lessonEngine.showMatchAnimation();
|
||||
|
||||
// Update navigation buttons
|
||||
updateNavigationButtons();
|
||||
|
||||
@@ -388,6 +405,9 @@ function runCode() {
|
||||
// Reset any success indicators
|
||||
resetSuccessIndicators();
|
||||
|
||||
// Hide merge animation if it was showing
|
||||
lessonEngine.hideMatchAnimation();
|
||||
|
||||
// Show error feedback (with friendly message)
|
||||
showFeedback(false, validationResult.message || "Not quite there yet! Let's try again.");
|
||||
}
|
||||
|
||||
@@ -6,9 +6,20 @@
|
||||
import basicSelectorsConfig from "../../lessons/00-basic-selectors.json";
|
||||
import advancedSelectorsConfig from "../../lessons/01-advanced-selectors.json";
|
||||
import tailwindConfig from "../../lessons/10-tailwind-basics.json";
|
||||
// HTML lessons
|
||||
import htmlElementsConfig from "../../lessons/20-html-elements.json";
|
||||
import htmlFormsBasicConfig from "../../lessons/21-html-forms-basic.json";
|
||||
import htmlFormsValidationConfig from "../../lessons/22-html-forms-validation.json";
|
||||
|
||||
// Module store
|
||||
const moduleStore = [basicSelectorsConfig, advancedSelectorsConfig, tailwindConfig];
|
||||
const moduleStore = [
|
||||
htmlElementsConfig,
|
||||
htmlFormsBasicConfig,
|
||||
htmlFormsValidationConfig,
|
||||
basicSelectorsConfig,
|
||||
advancedSelectorsConfig,
|
||||
tailwindConfig
|
||||
];
|
||||
|
||||
/**
|
||||
* Load all available modules
|
||||
|
||||
@@ -15,7 +15,7 @@ let feedbackTimeout = null;
|
||||
*/
|
||||
export function renderModuleList(container, modules, onSelectModule, onSelectLesson) {
|
||||
// Clear the container
|
||||
container.innerHTML = "<h3>CSS Lessons</h3>";
|
||||
container.innerHTML = "<h3>Lessons</h3>";
|
||||
|
||||
// Get user progress from localStorage
|
||||
const progressData = localStorage.getItem("codeCrispies.progress");
|
||||
|
||||
@@ -5,13 +5,205 @@
|
||||
export function validateUserCode(userCode, lesson) {
|
||||
const mode = lesson.mode || "css";
|
||||
|
||||
if (mode === "tailwind") {
|
||||
return validateTailwindClasses(userCode, lesson);
|
||||
} else {
|
||||
return validateCssCode(userCode, lesson);
|
||||
switch (mode) {
|
||||
case "html":
|
||||
return validateHtmlCode(userCode, lesson);
|
||||
case "tailwind":
|
||||
return validateTailwindClasses(userCode, lesson);
|
||||
case "css":
|
||||
default:
|
||||
return validateCssCode(userCode, lesson);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate user HTML code against the lesson requirements
|
||||
* @param {string} userHtml - User submitted HTML code
|
||||
* @param {Object} lesson - The current lesson object
|
||||
* @returns {Object} Validation result with isValid and message properties
|
||||
*/
|
||||
function validateHtmlCode(userHtml, lesson) {
|
||||
if (!lesson || !lesson.validations) {
|
||||
return { isValid: true, message: "No validations specified for this lesson." };
|
||||
}
|
||||
|
||||
// Parse the HTML using DOMParser
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(userHtml, "text/html");
|
||||
|
||||
// Check for parse errors (DOMParser doesn't throw, but inserts error elements for XML)
|
||||
// For HTML mode, it's more lenient, so we mainly check validations
|
||||
|
||||
const validations = lesson.validations;
|
||||
|
||||
let result = {
|
||||
isValid: true,
|
||||
validCases: 0,
|
||||
totalCases: validations.length,
|
||||
message: "Your HTML looks great!"
|
||||
};
|
||||
|
||||
for (const validation of validations) {
|
||||
const { type, value, message } = validation;
|
||||
let validationPassed = false;
|
||||
|
||||
switch (type) {
|
||||
case "element_exists":
|
||||
// value is a CSS selector string
|
||||
validationPassed = doc.querySelector(value) !== null;
|
||||
if (!validationPassed) {
|
||||
result = {
|
||||
...result,
|
||||
isValid: false,
|
||||
message: message || `Missing element: ${value}`
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
case "element_count":
|
||||
// value is { selector: string, count?: number, min?: number }
|
||||
const elements = doc.querySelectorAll(value.selector);
|
||||
if (value.count !== undefined) {
|
||||
validationPassed = elements.length === value.count;
|
||||
} else if (value.min !== undefined) {
|
||||
validationPassed = elements.length >= value.min;
|
||||
} else {
|
||||
validationPassed = elements.length > 0;
|
||||
}
|
||||
if (!validationPassed) {
|
||||
result = {
|
||||
...result,
|
||||
isValid: false,
|
||||
message: message || `Expected ${value.count || value.min + "+"} ${value.selector} element(s)`
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
case "attribute_value":
|
||||
// value is { selector: string, attr: string, value: any }
|
||||
const el = doc.querySelector(value.selector);
|
||||
if (!el) {
|
||||
validationPassed = false;
|
||||
} else if (value.value === true) {
|
||||
// Check attribute exists (boolean attribute like "required")
|
||||
validationPassed = el.hasAttribute(value.attr);
|
||||
} else if (value.value === null) {
|
||||
// Check attribute exists with any value
|
||||
validationPassed = el.hasAttribute(value.attr);
|
||||
} else {
|
||||
// Check attribute has specific value
|
||||
validationPassed = el.getAttribute(value.attr) === value.value;
|
||||
}
|
||||
if (!validationPassed) {
|
||||
result = {
|
||||
...result,
|
||||
isValid: false,
|
||||
message: message || `Element ${value.selector} should have ${value.attr} attribute`
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
case "element_text":
|
||||
// value is { selector: string, text: string }
|
||||
const textEl = doc.querySelector(value.selector);
|
||||
validationPassed = textEl && textEl.textContent.includes(value.text);
|
||||
if (!validationPassed) {
|
||||
result = {
|
||||
...result,
|
||||
isValid: false,
|
||||
message: message || `Element ${value.selector} should contain "${value.text}"`
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
case "parent_child":
|
||||
// value is { parent: string, child: string }
|
||||
const parentEl = doc.querySelector(value.parent);
|
||||
validationPassed = parentEl && parentEl.querySelector(value.child) !== null;
|
||||
if (!validationPassed) {
|
||||
result = {
|
||||
...result,
|
||||
isValid: false,
|
||||
message: message || `${value.child} should be inside ${value.parent}`
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
case "sibling":
|
||||
// value is { first: string, then: string }
|
||||
const firstSibling = doc.querySelector(value.first);
|
||||
if (!firstSibling) {
|
||||
validationPassed = false;
|
||||
} else {
|
||||
// Check if "then" element comes after "first" element
|
||||
let nextEl = firstSibling.nextElementSibling;
|
||||
while (nextEl) {
|
||||
if (nextEl.matches(value.then)) {
|
||||
validationPassed = true;
|
||||
break;
|
||||
}
|
||||
nextEl = nextEl.nextElementSibling;
|
||||
}
|
||||
}
|
||||
if (!validationPassed) {
|
||||
result = {
|
||||
...result,
|
||||
isValid: false,
|
||||
message: message || `${value.then} should follow ${value.first}`
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
// Fall back to text-based validations for simple checks
|
||||
case "contains":
|
||||
validationPassed = containsValidation(userHtml, value);
|
||||
if (!validationPassed) {
|
||||
result = {
|
||||
...result,
|
||||
isValid: false,
|
||||
message: message || `Your HTML should include "${value}"`
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
case "not_contains":
|
||||
validationPassed = !containsValidation(userHtml, value);
|
||||
if (!validationPassed) {
|
||||
result = {
|
||||
...result,
|
||||
isValid: false,
|
||||
message: message || `Your HTML should not include "${value}"`
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
case "regex":
|
||||
validationPassed = regexValidation(userHtml, value);
|
||||
if (!validationPassed) {
|
||||
result = {
|
||||
...result,
|
||||
isValid: false,
|
||||
message: message || "Your HTML doesn't match the expected pattern"
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`Unknown HTML validation type: ${type}`);
|
||||
validationPassed = true;
|
||||
}
|
||||
|
||||
if (validationPassed) {
|
||||
result.validCases++;
|
||||
} else {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
result.validCases = validations.length;
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateTailwindClasses(userClasses, lesson) {
|
||||
if (!lesson || !lesson.validations) {
|
||||
return { isValid: true, message: "No validations specified for this lesson." };
|
||||
|
||||
@@ -176,7 +176,22 @@ export class LessonEngine {
|
||||
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
|
||||
iframeDoc.open();
|
||||
|
||||
if (mode === "tailwind") {
|
||||
if (mode === "html") {
|
||||
// For HTML mode, user code IS the HTML content
|
||||
const userHtml = this.userCode || "";
|
||||
iframeDoc.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>${previewBaseCSS || ""}</style>
|
||||
<style>${sandboxCSS || ""}</style>
|
||||
</head>
|
||||
<body>
|
||||
${userHtml}
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} else if (mode === "tailwind") {
|
||||
// For Tailwind mode, user code goes directly in HTML classes
|
||||
const htmlWithClasses = this.injectTailwindClasses(previewHTML, this.userCode);
|
||||
iframeDoc.write(`
|
||||
@@ -218,6 +233,122 @@ export class LessonEngine {
|
||||
return html.replace(/{{USER_CLASSES}}/g, userClasses);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the expected/solution preview for comparison
|
||||
*/
|
||||
renderExpectedPreview() {
|
||||
if (!this.currentLesson) return;
|
||||
|
||||
const solutionCode = this.currentLesson.solutionCode;
|
||||
if (!solutionCode) {
|
||||
// No solution code provided, hide the expected pane or show placeholder
|
||||
const expectedContainer = document.getElementById("preview-expected");
|
||||
if (expectedContainer) {
|
||||
expectedContainer.innerHTML = '<div style="color: #999; font-size: 0.9rem; text-align: center;">No expected output available</div>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = this.currentLesson.mode || this.currentModule?.mode || "css";
|
||||
const { previewHTML, previewBaseCSS, sandboxCSS } = this.currentLesson;
|
||||
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.style.width = "100%";
|
||||
iframe.style.height = "100%";
|
||||
iframe.style.border = "none";
|
||||
iframe.title = "Expected Result";
|
||||
|
||||
const container = document.getElementById("preview-expected");
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = "";
|
||||
container.appendChild(iframe);
|
||||
|
||||
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
|
||||
iframeDoc.open();
|
||||
|
||||
if (mode === "html") {
|
||||
// For HTML mode, solution code IS the HTML content
|
||||
iframeDoc.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>${previewBaseCSS || ""}</style>
|
||||
<style>${sandboxCSS || ""}</style>
|
||||
</head>
|
||||
<body>
|
||||
${solutionCode}
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} else if (mode === "tailwind") {
|
||||
// For Tailwind mode, inject solution classes into HTML
|
||||
const htmlWithClasses = this.injectTailwindClasses(previewHTML, solutionCode);
|
||||
iframeDoc.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>${previewBaseCSS}</style>
|
||||
<style>${sandboxCSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
${htmlWithClasses}
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} else {
|
||||
// CSS mode - wrap solution with prefix/suffix
|
||||
const { codePrefix, codeSuffix } = this.currentLesson;
|
||||
const solutionCss = `${codePrefix || ""}${solutionCode}${codeSuffix || ""}`;
|
||||
iframeDoc.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>${previewBaseCSS}</style>
|
||||
<style>${solutionCss}</style>
|
||||
<style>${sandboxCSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
${previewHTML}
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
|
||||
iframeDoc.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show merge animation when student's output matches expected
|
||||
*/
|
||||
showMatchAnimation() {
|
||||
const overlay = document.getElementById("match-overlay");
|
||||
const comparison = document.getElementById("preview-comparison");
|
||||
|
||||
if (overlay && comparison) {
|
||||
overlay.classList.add("matched");
|
||||
comparison.classList.add("matched");
|
||||
|
||||
// Remove animation classes after delay
|
||||
setTimeout(() => {
|
||||
overlay.classList.remove("matched");
|
||||
comparison.classList.remove("matched");
|
||||
}, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide match animation
|
||||
*/
|
||||
hideMatchAnimation() {
|
||||
const overlay = document.getElementById("match-overlay");
|
||||
const comparison = document.getElementById("preview-comparison");
|
||||
|
||||
if (overlay) overlay.classList.remove("matched");
|
||||
if (comparison) comparison.classList.remove("matched");
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate user code against the current lesson's requirements
|
||||
* @returns {Object} Validation result
|
||||
|
||||
@@ -46,8 +46,28 @@
|
||||
<div class="lesson-description" id="lesson-description">Please select a lesson to begin.</div>
|
||||
|
||||
<div class="challenge-container">
|
||||
<div class="preview-area" id="preview-area">
|
||||
<!-- Preview of the challenge will be shown here -->
|
||||
<div class="preview-comparison" id="preview-comparison">
|
||||
<div class="preview-pane preview-student">
|
||||
<div class="preview-header">
|
||||
<span class="preview-label">Your Output</span>
|
||||
</div>
|
||||
<div class="preview-frame" id="preview-area">
|
||||
<!-- Student's preview iframe will be shown here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-pane preview-expected">
|
||||
<div class="preview-header">
|
||||
<span class="preview-label">Expected Result</span>
|
||||
</div>
|
||||
<div class="preview-frame" id="preview-expected">
|
||||
<!-- Expected result iframe will be shown here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-overlay" id="match-overlay">
|
||||
<div class="match-celebration">Perfect Match!</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-container">
|
||||
|
||||
111
src/main.css
111
src/main.css
@@ -367,13 +367,99 @@ footer a {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.preview-area {
|
||||
background-color: var(--panel-bg);
|
||||
/* ================= PREVIEW COMPARISON ================= */
|
||||
.preview-comparison {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
min-height: 300px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.preview-pane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: var(--spacing-md);
|
||||
overflow: hidden;
|
||||
min-height: 300px;
|
||||
background-color: var(--panel-bg);
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
background-color: var(--code-bg);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--light-text);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.preview-frame {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: var(--spacing-sm);
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* Merge overlay when student matches expected */
|
||||
.preview-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.5s ease;
|
||||
z-index: 10;
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
|
||||
.preview-overlay.matched {
|
||||
opacity: 1;
|
||||
background: rgba(88, 184, 144, 0.15);
|
||||
}
|
||||
|
||||
.match-celebration {
|
||||
background: var(--success-color);
|
||||
color: var(--white-text);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-radius: var(--border-radius-lg);
|
||||
font-weight: 700;
|
||||
font-size: 1.2rem;
|
||||
box-shadow: 0 4px 20px rgba(88, 184, 144, 0.3);
|
||||
animation: pop-in 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes pop-in {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Both previews visually "merge" at 50% opacity */
|
||||
.preview-comparison.matched .preview-pane {
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
/* Legacy preview-area styles (now used inside preview-frame) */
|
||||
.preview-area {
|
||||
background-color: var(--panel-bg);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -795,12 +881,23 @@ input:checked + .toggle-slider:before {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.preview-area,
|
||||
.preview-comparison,
|
||||
.editor-container {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive: Stack preview panes on medium screens */
|
||||
@media (max-width: 1200px) {
|
||||
.preview-comparison {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.preview-pane {
|
||||
min-height: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.main-content {
|
||||
flex-direction: column;
|
||||
@@ -824,12 +921,12 @@ input:checked + .toggle-slider:before {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.preview-area,
|
||||
.preview-comparison,
|
||||
.editor-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.preview-area {
|
||||
.preview-comparison {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,36 @@
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
import { loadModules, getModuleById, loadModuleFromUrl, addCustomModule } from "../../src/config/lessons.js";
|
||||
|
||||
// Mock the module store for testing
|
||||
vi.mock("../../lessons/flexbox.json", () => ({ default: { id: "flexbox", title: "Flexbox", lessons: [] } }));
|
||||
vi.mock("../../lessons/grid.json", () => ({ default: { id: "grid", title: "CSS Grid", lessons: [] } }));
|
||||
vi.mock("../../lessons/00-basics.json", () => ({ default: { id: "basics", title: "CSS Basics", lessons: [] } }));
|
||||
vi.mock("../../lessons/tailwindcss.json", () => ({ default: { id: "tailwind", title: "Tailwind CSS", lessons: [] } }));
|
||||
|
||||
describe("Lessons Config Module", () => {
|
||||
describe("loadModules", () => {
|
||||
test("should return an array of modules", async () => {
|
||||
const modules = await loadModules();
|
||||
|
||||
expect(Array.isArray(modules)).toBe(true);
|
||||
expect(modules.length).toBe(4);
|
||||
expect(modules.length).toBe(6);
|
||||
|
||||
// Check if modules have the right structure
|
||||
const moduleIds = modules.map((m) => m.id);
|
||||
expect(moduleIds).toContain("basics");
|
||||
expect(moduleIds).toContain("flexbox");
|
||||
expect(moduleIds).toContain("grid");
|
||||
expect(moduleIds).toContain("tailwind");
|
||||
// HTML modules (first)
|
||||
expect(moduleIds).toContain("html-elements");
|
||||
expect(moduleIds).toContain("html-forms-basic");
|
||||
expect(moduleIds).toContain("html-forms-validation");
|
||||
// CSS modules
|
||||
expect(moduleIds).toContain("css-basic-selectors");
|
||||
expect(moduleIds).toContain("css-advanced-selectors");
|
||||
// Tailwind
|
||||
expect(moduleIds).toContain("tailwind-basics");
|
||||
});
|
||||
|
||||
test("should have mode set on each lesson", async () => {
|
||||
const modules = await loadModules();
|
||||
|
||||
modules.forEach((module) => {
|
||||
module.lessons.forEach((lesson) => {
|
||||
expect(lesson.mode).toBeDefined();
|
||||
expect(["html", "css", "tailwind"]).toContain(lesson.mode);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,10 +39,10 @@ describe("Lessons Config Module", () => {
|
||||
// Load modules first to populate the module store
|
||||
await loadModules();
|
||||
|
||||
const flexboxModule = getModuleById("flexbox");
|
||||
expect(flexboxModule).not.toBeNull();
|
||||
expect(flexboxModule.id).toBe("flexbox");
|
||||
expect(flexboxModule.title).toBe("Flexbox");
|
||||
const htmlModule = getModuleById("html-elements");
|
||||
expect(htmlModule).not.toBeNull();
|
||||
expect(htmlModule.id).toBe("html-elements");
|
||||
expect(htmlModule.mode).toBe("html");
|
||||
});
|
||||
|
||||
test("should return null for non-existent module ID", async () => {
|
||||
|
||||
@@ -22,33 +22,59 @@ describe("Renderer Module", () => {
|
||||
test("should render a list of modules", () => {
|
||||
const container = document.getElementById("module-list");
|
||||
const modules = [
|
||||
{ id: "mod1", title: "Module 1" },
|
||||
{ id: "mod2", title: "Module 2" }
|
||||
{ id: "mod1", title: "Module 1", lessons: [{ title: "Lesson 1" }] },
|
||||
{ id: "mod2", title: "Module 2", lessons: [{ title: "Lesson 2" }] }
|
||||
];
|
||||
const onSelectModule = vi.fn();
|
||||
const onSelectLesson = vi.fn();
|
||||
|
||||
renderModuleList(container, modules, onSelectModule);
|
||||
renderModuleList(container, modules, onSelectModule, onSelectLesson);
|
||||
|
||||
// Check if heading is created
|
||||
expect(container.innerHTML).toContain("<h3>Modules</h3>");
|
||||
expect(container.innerHTML).toContain("<h3>Lessons</h3>");
|
||||
|
||||
// Check if module items are created
|
||||
const moduleItems = container.querySelectorAll(".module-list-item");
|
||||
expect(moduleItems.length).toBe(2);
|
||||
expect(moduleItems[0].textContent).toBe("Module 1");
|
||||
expect(moduleItems[1].textContent).toBe("Module 2");
|
||||
// Check if module headers are created
|
||||
const moduleHeaders = container.querySelectorAll(".module-header");
|
||||
expect(moduleHeaders.length).toBe(2);
|
||||
|
||||
// Test click event
|
||||
moduleItems[0].click();
|
||||
expect(onSelectModule).toHaveBeenCalledWith("mod1");
|
||||
// Module titles should be in the headers
|
||||
expect(moduleHeaders[0].textContent).toContain("Module 1");
|
||||
expect(moduleHeaders[1].textContent).toContain("Module 2");
|
||||
});
|
||||
|
||||
test("should handle empty module list", () => {
|
||||
const container = document.getElementById("module-list");
|
||||
renderModuleList(container, [], vi.fn());
|
||||
renderModuleList(container, [], vi.fn(), vi.fn());
|
||||
|
||||
expect(container.innerHTML).toContain("<h3>Modules</h3>");
|
||||
expect(container.querySelectorAll(".module-list-item").length).toBe(0);
|
||||
expect(container.innerHTML).toContain("<h3>Lessons</h3>");
|
||||
expect(container.querySelectorAll(".module-header").length).toBe(0);
|
||||
});
|
||||
|
||||
test("should expand module and show lessons on click", () => {
|
||||
const container = document.getElementById("module-list");
|
||||
const modules = [
|
||||
{ id: "mod1", title: "Module 1", lessons: [{ title: "Lesson A" }, { title: "Lesson B" }] }
|
||||
];
|
||||
const onSelectLesson = vi.fn();
|
||||
|
||||
renderModuleList(container, modules, vi.fn(), onSelectLesson);
|
||||
|
||||
// Lessons should be hidden initially
|
||||
const lessonsContainer = container.querySelector(".lessons-container");
|
||||
expect(lessonsContainer.style.display).toBe("none");
|
||||
|
||||
// Click module header to expand
|
||||
const moduleHeader = container.querySelector(".module-header");
|
||||
moduleHeader.click();
|
||||
|
||||
// Lessons should now be visible
|
||||
expect(lessonsContainer.style.display).toBe("block");
|
||||
|
||||
// Click a lesson
|
||||
const lessonItems = container.querySelectorAll(".lesson-list-item");
|
||||
expect(lessonItems.length).toBe(2);
|
||||
lessonItems[0].click();
|
||||
expect(onSelectLesson).toHaveBeenCalledWith("mod1", 0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,9 +102,8 @@ describe("Renderer Module", () => {
|
||||
expect(titleEl.textContent).toBe("Test Lesson");
|
||||
expect(descriptionEl.innerHTML).toBe("<p>Description text</p>");
|
||||
expect(taskEl.innerHTML).toBe("<p>Task instructions</p>");
|
||||
expect(prefixEl.textContent).toBe("body {");
|
||||
// Note: prefix/suffix elements are no longer populated by renderLesson (handled by LessonEngine)
|
||||
expect(inputEl.value).toBe(" color: red;");
|
||||
expect(suffixEl.textContent).toBe("}");
|
||||
});
|
||||
|
||||
test("should handle missing lesson data with defaults", () => {
|
||||
@@ -97,9 +122,7 @@ describe("Renderer Module", () => {
|
||||
expect(titleEl.textContent).toBe("Untitled Lesson");
|
||||
expect(descriptionEl.innerHTML).toBe("");
|
||||
expect(taskEl.innerHTML).toBe("");
|
||||
expect(prefixEl.textContent).toBe("");
|
||||
expect(inputEl.value).toBe("");
|
||||
expect(suffixEl.textContent).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ describe("CSS Validator", () => {
|
||||
const result = validateUserCode(userCode, lesson);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.message).toBe("Your code looks good!");
|
||||
expect(result.message).toBe("Your CODE looks CRISPY!");
|
||||
});
|
||||
|
||||
it('should validate "contains" rule correctly', () => {
|
||||
@@ -225,3 +225,148 @@ describe("CSS Validator", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTML Validator", () => {
|
||||
describe("validateUserCode with mode: html", () => {
|
||||
it("should validate element_exists correctly", () => {
|
||||
const userHtml = "<p>Hello world</p>";
|
||||
const lesson = {
|
||||
mode: "html",
|
||||
validations: [
|
||||
{ type: "element_exists", value: "p", message: "Add a paragraph" }
|
||||
]
|
||||
};
|
||||
|
||||
const result = validateUserCode(userHtml, lesson);
|
||||
expect(result.isValid).toBe(true);
|
||||
|
||||
const failResult = validateUserCode(userHtml, {
|
||||
mode: "html",
|
||||
validations: [
|
||||
{ type: "element_exists", value: "div", message: "Add a div" }
|
||||
]
|
||||
});
|
||||
expect(failResult.isValid).toBe(false);
|
||||
expect(failResult.message).toBe("Add a div");
|
||||
});
|
||||
|
||||
it("should validate element_count correctly", () => {
|
||||
const userHtml = "<ul><li>One</li><li>Two</li><li>Three</li></ul>";
|
||||
const lesson = {
|
||||
mode: "html",
|
||||
validations: [
|
||||
{ type: "element_count", value: { selector: "li", count: 3 }, message: "Need 3 items" }
|
||||
]
|
||||
};
|
||||
|
||||
const result = validateUserCode(userHtml, lesson);
|
||||
expect(result.isValid).toBe(true);
|
||||
|
||||
const failLesson = {
|
||||
mode: "html",
|
||||
validations: [
|
||||
{ type: "element_count", value: { selector: "li", count: 5 }, message: "Need 5 items" }
|
||||
]
|
||||
};
|
||||
const failResult = validateUserCode(userHtml, failLesson);
|
||||
expect(failResult.isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("should validate element_count with min correctly", () => {
|
||||
const userHtml = "<div><span>A</span><span>B</span></div>";
|
||||
const lesson = {
|
||||
mode: "html",
|
||||
validations: [
|
||||
{ type: "element_count", value: { selector: "span", min: 2 }, message: "Need at least 2 spans" }
|
||||
]
|
||||
};
|
||||
|
||||
const result = validateUserCode(userHtml, lesson);
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it("should validate attribute_value correctly", () => {
|
||||
const userHtml = '<input type="email" required>';
|
||||
const lesson = {
|
||||
mode: "html",
|
||||
validations: [
|
||||
{ type: "attribute_value", value: { selector: "input", attr: "type", value: "email" } }
|
||||
]
|
||||
};
|
||||
|
||||
const result = validateUserCode(userHtml, lesson);
|
||||
expect(result.isValid).toBe(true);
|
||||
|
||||
// Test boolean attribute (required)
|
||||
const boolLesson = {
|
||||
mode: "html",
|
||||
validations: [
|
||||
{ type: "attribute_value", value: { selector: "input", attr: "required", value: true } }
|
||||
]
|
||||
};
|
||||
const boolResult = validateUserCode(userHtml, boolLesson);
|
||||
expect(boolResult.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it("should validate parent_child correctly", () => {
|
||||
const userHtml = "<form><label>Name</label><input></form>";
|
||||
const lesson = {
|
||||
mode: "html",
|
||||
validations: [
|
||||
{ type: "parent_child", value: { parent: "form", child: "input" }, message: "Input should be inside form" }
|
||||
]
|
||||
};
|
||||
|
||||
const result = validateUserCode(userHtml, lesson);
|
||||
expect(result.isValid).toBe(true);
|
||||
|
||||
const failHtml = "<label>Name</label><input>";
|
||||
const failResult = validateUserCode(failHtml, lesson);
|
||||
expect(failResult.isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("should validate element_text correctly", () => {
|
||||
const userHtml = "<button>Submit</button>";
|
||||
const lesson = {
|
||||
mode: "html",
|
||||
validations: [
|
||||
{ type: "element_text", value: { selector: "button", text: "Submit" } }
|
||||
]
|
||||
};
|
||||
|
||||
const result = validateUserCode(userHtml, lesson);
|
||||
expect(result.isValid).toBe(true);
|
||||
|
||||
const failLesson = {
|
||||
mode: "html",
|
||||
validations: [
|
||||
{ type: "element_text", value: { selector: "button", text: "Cancel" }, message: "Button should say Cancel" }
|
||||
]
|
||||
};
|
||||
const failResult = validateUserCode(userHtml, failLesson);
|
||||
expect(failResult.isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("should validate contains for HTML mode", () => {
|
||||
const userHtml = '<div class="container">Content</div>';
|
||||
const lesson = {
|
||||
mode: "html",
|
||||
validations: [
|
||||
{ type: "contains", value: "container" }
|
||||
]
|
||||
};
|
||||
|
||||
const result = validateUserCode(userHtml, lesson);
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it("should pass with no validations in HTML mode", () => {
|
||||
const userHtml = "<p>Hello</p>";
|
||||
const lesson = { mode: "html" };
|
||||
|
||||
const result = validateUserCode(userHtml, lesson);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.message).toContain("No validations specified");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user