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:
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user