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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-21 22:12:00 +01:00
parent 94cdf368bc
commit 862d29aa19
15 changed files with 1136 additions and 66 deletions

View File

@@ -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.");
}

View File

@@ -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

View File

@@ -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");

View File

@@ -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." };

View File

@@ -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

View File

@@ -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">

View File

@@ -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;
}