feat: add Guided Learning Paths feature
Implement PathManager to orchestrate multi-module learning journeys: - Add PathManager class with start/pause/resume functionality - Create learning-paths.json config with CSS Fundamentals path - Integrate path progress tracking with LessonEngine - Add path selection UI to homepage and navigation - Include JSON schema for learning path validation - Add comprehensive test suite for PathManager
This commit is contained in:
408
src/app.js
408
src/app.js
@@ -1,8 +1,9 @@
|
||||
import { LessonEngine } from "./impl/LessonEngine.js";
|
||||
import { CodeEditor } from "./impl/CodeEditor.js";
|
||||
import { renderLesson, renderModuleList, renderLevelIndicator, updateActiveLessonInSidebar } from "./helpers/renderer.js";
|
||||
import { loadModules } from "./config/lessons.js";
|
||||
import { renderLesson, renderModuleList, renderLevelIndicator, updateActiveLessonInSidebar, renderPathList } from "./helpers/renderer.js";
|
||||
import { loadModules, loadLearningPaths } from "./config/lessons.js";
|
||||
import { initI18n, t, getLanguage, setLanguage, applyTranslations } from "./i18n.js";
|
||||
import { PathManager } from "./impl/PathManager.js";
|
||||
|
||||
// Simplified state - LessonEngine now manages lesson state and progress
|
||||
const state = {
|
||||
@@ -20,6 +21,7 @@ const elements = {
|
||||
logoLink: document.getElementById("logo-link"),
|
||||
langSelect: document.getElementById("lang-select"),
|
||||
helpBtn: document.getElementById("help-btn"),
|
||||
pathIndicator: document.getElementById("path-indicator"),
|
||||
|
||||
// Left panel
|
||||
instructionsSection: document.querySelector(".instructions"),
|
||||
@@ -45,6 +47,7 @@ const elements = {
|
||||
previewWrapper: document.querySelector(".preview-wrapper"),
|
||||
prevBtn: document.getElementById("prev-btn"),
|
||||
nextBtn: document.getElementById("next-btn"),
|
||||
nextInPathBtn: document.getElementById("next-in-path-btn"),
|
||||
levelIndicator: document.getElementById("level-indicator"),
|
||||
|
||||
// Sidebar
|
||||
@@ -56,6 +59,9 @@ const elements = {
|
||||
progressText: document.getElementById("progress-text"),
|
||||
resetBtn: document.getElementById("reset-btn"),
|
||||
disableFeedbackToggle: document.getElementById("disable-feedback-toggle"),
|
||||
viewPathsBtn: document.getElementById("view-paths-btn"),
|
||||
pathProgressDisplay: document.getElementById("path-progress-display"),
|
||||
pathProgressFill: document.getElementById("path-progress-fill"),
|
||||
|
||||
// Dialogs
|
||||
helpDialog: document.getElementById("help-dialog"),
|
||||
@@ -68,12 +74,28 @@ const elements = {
|
||||
resetCodeDialogClose: document.getElementById("reset-code-dialog-close"),
|
||||
cancelResetCode: document.getElementById("cancel-reset-code"),
|
||||
confirmResetCode: document.getElementById("confirm-reset-code"),
|
||||
resetCodeDontShow: document.getElementById("reset-code-dont-show")
|
||||
resetCodeDontShow: document.getElementById("reset-code-dont-show"),
|
||||
pathsDialog: document.getElementById("paths-dialog"),
|
||||
pathsDialogClose: document.getElementById("paths-dialog-close"),
|
||||
pathsList: document.getElementById("paths-list"),
|
||||
pathCompletionDialog: document.getElementById("path-completion-dialog"),
|
||||
pathCompletionDialogClose: document.getElementById("path-completion-dialog-close"),
|
||||
completionLessonsCount: document.getElementById("completion-lessons-count"),
|
||||
completionTimeTaken: document.getElementById("completion-time-taken"),
|
||||
nextPathSuggestion: document.getElementById("next-path-suggestion"),
|
||||
suggestedPathTitle: document.getElementById("suggested-path-title"),
|
||||
suggestedPathGoal: document.getElementById("suggested-path-goal"),
|
||||
startSuggestedPathBtn: document.getElementById("start-suggested-path-btn"),
|
||||
viewAllPathsFromCompletion: document.getElementById("view-all-paths-from-completion"),
|
||||
closeCompletionDialog: document.getElementById("close-completion-dialog")
|
||||
};
|
||||
|
||||
// Initialize the lesson engine - now the single source of truth
|
||||
const lessonEngine = new LessonEngine();
|
||||
|
||||
// Initialize the path manager - handles learning path state and progress
|
||||
const pathManager = new PathManager();
|
||||
|
||||
// Code editor instance (initialized later)
|
||||
let codeEditor = null;
|
||||
let currentMode = "css";
|
||||
@@ -137,7 +159,12 @@ function changeLanguage(newLang) {
|
||||
|
||||
const modules = loadModules(newLang);
|
||||
lessonEngine.setModules(modules);
|
||||
renderModuleList(elements.moduleList, modules, selectModule, selectLesson);
|
||||
|
||||
// Reload learning paths in new language
|
||||
const learningPaths = loadLearningPaths(newLang);
|
||||
pathManager.setPaths(learningPaths);
|
||||
|
||||
renderModuleList(elements.moduleList, modules, selectModule, selectLesson, pathManager);
|
||||
|
||||
// Restore position in current module/lesson
|
||||
if (currentModuleId) {
|
||||
@@ -191,6 +218,38 @@ function updateProgressDisplay() {
|
||||
});
|
||||
}
|
||||
|
||||
function updatePathProgressDisplay() {
|
||||
const activePath = pathManager.getActivePath();
|
||||
|
||||
if (activePath && elements.pathProgressDisplay) {
|
||||
// Show path progress section
|
||||
elements.pathProgressDisplay.style.display = "block";
|
||||
|
||||
// Get path progress data
|
||||
const pathProgress = pathManager.getPathProgress(activePath.id);
|
||||
|
||||
// Update path name
|
||||
const pathNameEl = elements.pathProgressDisplay.querySelector(".path-progress-name");
|
||||
if (pathNameEl) {
|
||||
pathNameEl.textContent = activePath.title;
|
||||
}
|
||||
|
||||
// Update path stats
|
||||
const pathStatsEl = elements.pathProgressDisplay.querySelector(".path-progress-stats");
|
||||
if (pathStatsEl && pathProgress) {
|
||||
pathStatsEl.textContent = `${pathProgress.completedCount} / ${pathProgress.totalLessons} ${t("lessons")}`;
|
||||
}
|
||||
|
||||
// Update progress bar
|
||||
if (elements.pathProgressFill && pathProgress) {
|
||||
elements.pathProgressFill.style.width = `${pathProgress.percentComplete}%`;
|
||||
}
|
||||
} else if (elements.pathProgressDisplay) {
|
||||
// Hide path progress section when no active path
|
||||
elements.pathProgressDisplay.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// ================= USER SETTINGS =================
|
||||
|
||||
function loadUserSettings() {
|
||||
@@ -274,11 +333,21 @@ function clearLoadingTimeout() {
|
||||
|
||||
function initializeModules() {
|
||||
try {
|
||||
const modules = loadModules(getLanguage());
|
||||
const currentLang = getLanguage();
|
||||
|
||||
// Load modules
|
||||
const modules = loadModules(currentLang);
|
||||
lessonEngine.setModules(modules);
|
||||
|
||||
// Load learning paths and connect to PathManager
|
||||
const learningPaths = loadLearningPaths(currentLang);
|
||||
pathManager.setPaths(learningPaths);
|
||||
|
||||
// Connect PathManager to LessonEngine
|
||||
lessonEngine.setPathManager(pathManager);
|
||||
|
||||
// Use the new renderModuleList function with both callbacks
|
||||
renderModuleList(elements.moduleList, modules, selectModule, selectLesson);
|
||||
renderModuleList(elements.moduleList, modules, selectModule, selectLesson, pathManager);
|
||||
|
||||
// Load saved progress and select appropriate module
|
||||
const progressData = lessonEngine.loadUserProgress();
|
||||
@@ -291,6 +360,8 @@ function initializeModules() {
|
||||
}
|
||||
|
||||
updateProgressDisplay();
|
||||
updatePathIndicator();
|
||||
updatePathProgressDisplay();
|
||||
clearLoadingTimeout();
|
||||
} catch (error) {
|
||||
console.error("Failed to load modules:", error);
|
||||
@@ -531,6 +602,27 @@ function updateNavigationButtons() {
|
||||
|
||||
elements.prevBtn.classList.toggle("btn-disabled", !engineState.canGoPrev);
|
||||
elements.nextBtn.classList.toggle("btn-disabled", !engineState.canGoNext);
|
||||
|
||||
// Show "Next in Path" button if a path is active
|
||||
const pathManager = lessonEngine.pathManager;
|
||||
if (pathManager) {
|
||||
const activePath = pathManager.getActivePath();
|
||||
const hasActivePath = activePath !== null;
|
||||
|
||||
if (hasActivePath) {
|
||||
const nextLesson = pathManager.getNextLesson(activePath.id);
|
||||
const hasNextInPath = nextLesson !== null;
|
||||
|
||||
// Show button only if there's a next lesson in the path
|
||||
elements.nextInPathBtn.style.display = hasNextInPath ? "" : "none";
|
||||
elements.nextInPathBtn.disabled = !hasNextInPath;
|
||||
} else {
|
||||
elements.nextInPathBtn.style.display = "none";
|
||||
}
|
||||
} else {
|
||||
// PathManager not initialized yet - hide the button
|
||||
elements.nextInPathBtn.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
function nextLesson() {
|
||||
@@ -557,6 +649,30 @@ function prevLesson() {
|
||||
}
|
||||
}
|
||||
|
||||
function nextLessonInPath() {
|
||||
// Check if PathManager is available (will be initialized in Phase 4)
|
||||
const pathManager = lessonEngine.pathManager;
|
||||
if (!pathManager) return;
|
||||
|
||||
const activePath = pathManager.getActivePath();
|
||||
if (!activePath) return;
|
||||
|
||||
// Get the next incomplete lesson in the path
|
||||
const nextLesson = pathManager.getNextLesson(activePath.id);
|
||||
if (!nextLesson) return;
|
||||
|
||||
// Navigate to the next lesson in the path
|
||||
const prevModuleId = lessonEngine.getCurrentState().module?.id;
|
||||
const success = lessonEngine.setModuleById(nextLesson.moduleId, nextLesson.lessonIndex);
|
||||
if (success) {
|
||||
const newModuleId = lessonEngine.getCurrentState().module?.id;
|
||||
if (newModuleId !== prevModuleId) {
|
||||
updateModuleHighlight(newModuleId);
|
||||
}
|
||||
loadCurrentLesson();
|
||||
}
|
||||
}
|
||||
|
||||
function updateModuleHighlight(moduleId) {
|
||||
const moduleItems = elements.moduleList.querySelectorAll(".module-header");
|
||||
moduleItems.forEach((item) => {
|
||||
@@ -634,6 +750,11 @@ function runCode() {
|
||||
|
||||
updateNavigationButtons();
|
||||
updateProgressDisplay();
|
||||
updatePathIndicator();
|
||||
updatePathProgressDisplay();
|
||||
|
||||
// Check if path is complete and show celebration
|
||||
checkPathCompletion();
|
||||
} else {
|
||||
// Reset success indicators
|
||||
resetSuccessIndicators();
|
||||
@@ -710,6 +831,244 @@ function handleResetCodeClick() {
|
||||
}
|
||||
}
|
||||
|
||||
// ================= LEARNING PATHS =================
|
||||
|
||||
function openPathsDialog() {
|
||||
// Render the path list
|
||||
const paths = pathManager.paths;
|
||||
if (paths && paths.length > 0) {
|
||||
renderPathList(elements.pathsList, paths, pathManager);
|
||||
}
|
||||
|
||||
elements.pathsDialog.showModal();
|
||||
}
|
||||
|
||||
function closePathsDialog() {
|
||||
elements.pathsDialog.close();
|
||||
}
|
||||
|
||||
function handlePathAction(pathId) {
|
||||
const activePath = pathManager.getActivePath();
|
||||
const pathProgress = pathManager.getPathProgress(pathId);
|
||||
|
||||
// Determine action based on current state
|
||||
if (pathProgress && pathProgress.isComplete) {
|
||||
// Review completed path - restart it
|
||||
pathManager.startPath(pathId);
|
||||
} else if (activePath && activePath.id === pathId) {
|
||||
// Continue active path - navigate to next lesson
|
||||
const nextLesson = pathManager.getNextLesson(pathId);
|
||||
if (nextLesson) {
|
||||
lessonEngine.setModuleById(nextLesson.moduleId, nextLesson.lessonIndex);
|
||||
loadCurrentLesson();
|
||||
}
|
||||
} else if (pathProgress && pathProgress.isStarted) {
|
||||
// Resume paused path
|
||||
pathManager.resumePath(pathId);
|
||||
} else {
|
||||
// Start new path
|
||||
pathManager.startPath(pathId);
|
||||
}
|
||||
|
||||
// Update UI
|
||||
updatePathIndicator();
|
||||
updatePathProgressDisplay();
|
||||
updateNavigationButtons();
|
||||
|
||||
// Refresh module list to update path highlighting
|
||||
const modules = lessonEngine.getModules();
|
||||
renderModuleList(elements.moduleList, modules, selectModule, selectLesson, pathManager);
|
||||
updateActiveLessonInSidebar(elements.moduleList, lessonEngine.getCurrentState());
|
||||
|
||||
// Close dialog and sidebar
|
||||
closePathsDialog();
|
||||
if (window.innerWidth <= 768) {
|
||||
closeSidebar();
|
||||
}
|
||||
|
||||
// If starting/resuming, navigate to the next incomplete lesson
|
||||
if (!pathProgress || !pathProgress.isComplete) {
|
||||
const nextLesson = pathManager.getNextLesson(pathId);
|
||||
if (nextLesson) {
|
||||
const prevModuleId = lessonEngine.getCurrentState().module?.id;
|
||||
lessonEngine.setModuleById(nextLesson.moduleId, nextLesson.lessonIndex);
|
||||
if (nextLesson.moduleId !== prevModuleId) {
|
||||
updateModuleHighlight(nextLesson.moduleId);
|
||||
}
|
||||
loadCurrentLesson();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updatePathIndicator() {
|
||||
const activePath = pathManager.getActivePath();
|
||||
|
||||
if (activePath) {
|
||||
// Show path indicator
|
||||
const pathProgress = pathManager.getPathProgress(activePath.id);
|
||||
const pathNameSpan = elements.pathIndicator.querySelector(".path-indicator-name");
|
||||
const pathProgressSpan = elements.pathIndicator.querySelector(".path-indicator-progress");
|
||||
|
||||
if (pathNameSpan) {
|
||||
pathNameSpan.textContent = activePath.title;
|
||||
}
|
||||
if (pathProgressSpan && pathProgress) {
|
||||
pathProgressSpan.textContent = `${pathProgress.percentComplete}%`;
|
||||
}
|
||||
|
||||
elements.pathIndicator.style.display = "";
|
||||
} else {
|
||||
// Hide path indicator
|
||||
elements.pathIndicator.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
function pauseActivePath() {
|
||||
pathManager.pausePath();
|
||||
updatePathIndicator();
|
||||
updatePathProgressDisplay();
|
||||
updateNavigationButtons();
|
||||
|
||||
// Refresh module list to update path highlighting
|
||||
const modules = lessonEngine.getModules();
|
||||
renderModuleList(elements.moduleList, modules, selectModule, selectLesson, pathManager);
|
||||
updateActiveLessonInSidebar(elements.moduleList, lessonEngine.getCurrentState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in milliseconds to human-readable time string
|
||||
* @param {number} milliseconds - Duration in milliseconds
|
||||
* @returns {string} Formatted time string (e.g., "45 min", "2h 30m")
|
||||
*/
|
||||
function formatTimeDuration(milliseconds) {
|
||||
const totalMinutes = Math.floor(milliseconds / (1000 * 60));
|
||||
|
||||
if (totalMinutes < 60) {
|
||||
return `${totalMinutes} min`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
|
||||
if (minutes === 0) {
|
||||
return `${hours}h`;
|
||||
}
|
||||
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recommended next path based on completed path and prerequisites
|
||||
* @param {string} completedPathId - ID of the completed path
|
||||
* @returns {Object|null} Recommended path object or null
|
||||
*/
|
||||
function getRecommendedNextPath(completedPathId) {
|
||||
const allPaths = pathManager.paths;
|
||||
const completedPath = allPaths.find(p => p.id === completedPathId);
|
||||
|
||||
if (!completedPath) return null;
|
||||
|
||||
// Find paths that list the completed path as a prerequisite
|
||||
const pathsWithCompletedAsPrereq = allPaths.filter(path => {
|
||||
return path.prerequisites && path.prerequisites.includes(completedPathId);
|
||||
});
|
||||
|
||||
// Return first path that has the completed path as prerequisite and is not yet completed
|
||||
for (const path of pathsWithCompletedAsPrereq) {
|
||||
const progress = pathManager.getPathProgress(path.id);
|
||||
if (!progress || !progress.isComplete) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
// If no paths have it as prerequisite, suggest next difficulty level
|
||||
const difficultyOrder = ["beginner", "intermediate", "advanced"];
|
||||
const currentDifficultyIndex = difficultyOrder.indexOf(completedPath.difficulty);
|
||||
|
||||
if (currentDifficultyIndex < difficultyOrder.length - 1) {
|
||||
const nextDifficulty = difficultyOrder[currentDifficultyIndex + 1];
|
||||
const sameDifficultyPaths = allPaths.filter(path => {
|
||||
const progress = pathManager.getPathProgress(path.id);
|
||||
return path.difficulty === nextDifficulty && (!progress || !progress.isComplete);
|
||||
});
|
||||
|
||||
if (sameDifficultyPaths.length > 0) {
|
||||
return sameDifficultyPaths[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, suggest any incomplete path
|
||||
for (const path of allPaths) {
|
||||
const progress = pathManager.getPathProgress(path.id);
|
||||
if (path.id !== completedPathId && (!progress || !progress.isComplete)) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show path completion celebration dialog
|
||||
* @param {string} pathId - ID of the completed path
|
||||
*/
|
||||
function showPathCompletionDialog(pathId) {
|
||||
const path = pathManager.paths.find(p => p.id === pathId);
|
||||
if (!path) return;
|
||||
|
||||
const pathProgress = pathManager.getPathProgress(pathId);
|
||||
if (!pathProgress) return;
|
||||
|
||||
// Calculate time taken
|
||||
const startTime = new Date(pathProgress.startTimestamp).getTime();
|
||||
const endTime = Date.now();
|
||||
const timeTaken = endTime - startTime;
|
||||
|
||||
// Update stats
|
||||
elements.completionLessonsCount.textContent = pathProgress.totalLessons;
|
||||
elements.completionTimeTaken.textContent = formatTimeDuration(timeTaken);
|
||||
|
||||
// Get recommended next path
|
||||
const recommendedPath = getRecommendedNextPath(pathId);
|
||||
|
||||
if (recommendedPath) {
|
||||
elements.suggestedPathTitle.textContent = recommendedPath.title;
|
||||
elements.suggestedPathGoal.textContent = recommendedPath.goal;
|
||||
elements.nextPathSuggestion.style.display = "";
|
||||
|
||||
// Store recommended path ID for the button handler
|
||||
elements.startSuggestedPathBtn.dataset.pathId = recommendedPath.id;
|
||||
} else {
|
||||
elements.nextPathSuggestion.style.display = "none";
|
||||
}
|
||||
|
||||
// Show the dialog
|
||||
elements.pathCompletionDialog.showModal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close path completion dialog
|
||||
*/
|
||||
function closePathCompletionDialog() {
|
||||
elements.pathCompletionDialog.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path is complete after lesson completion and show celebration
|
||||
*/
|
||||
function checkPathCompletion() {
|
||||
const activePath = pathManager.getActivePath();
|
||||
if (!activePath) return;
|
||||
|
||||
const pathProgress = pathManager.getPathProgress(activePath.id);
|
||||
if (pathProgress && pathProgress.isComplete) {
|
||||
// Small delay to let UI update before showing dialog
|
||||
setTimeout(() => {
|
||||
showPathCompletionDialog(activePath.id);
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ================= INITIALIZATION =================
|
||||
|
||||
function initCodeEditor() {
|
||||
@@ -772,6 +1131,7 @@ function init() {
|
||||
// Navigation
|
||||
elements.prevBtn.addEventListener("click", prevLesson);
|
||||
elements.nextBtn.addEventListener("click", nextLesson);
|
||||
elements.nextInPathBtn.addEventListener("click", nextLessonInPath);
|
||||
elements.runBtn.addEventListener("click", runCode);
|
||||
|
||||
// Editor tools
|
||||
@@ -803,6 +1163,42 @@ function init() {
|
||||
elements.cancelResetCode.addEventListener("click", closeResetCodeDialog);
|
||||
elements.confirmResetCode.addEventListener("click", handleResetCodeConfirm);
|
||||
|
||||
// Learning Paths Dialog
|
||||
elements.viewPathsBtn.addEventListener("click", openPathsDialog);
|
||||
elements.pathIndicator.addEventListener("click", openPathsDialog);
|
||||
elements.pathsDialogClose.addEventListener("click", closePathsDialog);
|
||||
elements.pathsDialog.addEventListener("click", (e) => {
|
||||
if (e.target === elements.pathsDialog) closePathsDialog();
|
||||
});
|
||||
// Delegated event handler for path action buttons
|
||||
elements.pathsList.addEventListener("click", (e) => {
|
||||
const button = e.target.closest(".path-card-action");
|
||||
if (button) {
|
||||
const card = button.closest(".path-card");
|
||||
if (card && card.dataset.pathId) {
|
||||
handlePathAction(card.dataset.pathId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Path Completion Dialog
|
||||
elements.pathCompletionDialogClose.addEventListener("click", closePathCompletionDialog);
|
||||
elements.pathCompletionDialog.addEventListener("click", (e) => {
|
||||
if (e.target === elements.pathCompletionDialog) closePathCompletionDialog();
|
||||
});
|
||||
elements.closeCompletionDialog.addEventListener("click", closePathCompletionDialog);
|
||||
elements.viewAllPathsFromCompletion.addEventListener("click", () => {
|
||||
closePathCompletionDialog();
|
||||
openPathsDialog();
|
||||
});
|
||||
elements.startSuggestedPathBtn.addEventListener("click", () => {
|
||||
const pathId = elements.startSuggestedPathBtn.dataset.pathId;
|
||||
if (pathId) {
|
||||
closePathCompletionDialog();
|
||||
handlePathAction(pathId);
|
||||
}
|
||||
});
|
||||
|
||||
// Settings
|
||||
elements.disableFeedbackToggle.addEventListener("change", (e) => {
|
||||
state.userSettings.disableFeedbackErrors = !e.target.checked;
|
||||
|
||||
56
src/config/learning-paths.json
Normal file
56
src/config/learning-paths.json
Normal file
@@ -0,0 +1,56 @@
|
||||
[
|
||||
{
|
||||
"id": "css-fundamentals",
|
||||
"title": "CSS Fundamentals",
|
||||
"goal": "Master the essential CSS concepts needed to style any website. Learn selectors, the box model, units, colors, and typography from the ground up.",
|
||||
"estimatedTime": 120,
|
||||
"difficulty": "beginner",
|
||||
"modules": [
|
||||
"css-basic-selectors",
|
||||
"box-model",
|
||||
"units-variables",
|
||||
"colors-backgrounds",
|
||||
"typography-fonts"
|
||||
],
|
||||
"prerequisites": []
|
||||
},
|
||||
{
|
||||
"id": "flexbox-master",
|
||||
"title": "Flexbox Master",
|
||||
"goal": "Become proficient in modern CSS layouts using Flexbox. Learn to create responsive, flexible layouts that adapt to any screen size.",
|
||||
"estimatedTime": 90,
|
||||
"difficulty": "intermediate",
|
||||
"modules": [
|
||||
"box-model",
|
||||
"flexbox",
|
||||
"responsive-design"
|
||||
],
|
||||
"prerequisites": ["css-fundamentals"]
|
||||
},
|
||||
{
|
||||
"id": "html-forms-expert",
|
||||
"title": "HTML Forms Expert",
|
||||
"goal": "Create accessible, user-friendly forms with proper validation and semantic HTML. Master form structure, input types, validation patterns, and progressive enhancement.",
|
||||
"estimatedTime": 75,
|
||||
"difficulty": "intermediate",
|
||||
"modules": [
|
||||
"html-elements",
|
||||
"html-forms-basic",
|
||||
"html-forms-validation",
|
||||
"html-forms-fieldset",
|
||||
"html-datalist"
|
||||
],
|
||||
"prerequisites": []
|
||||
},
|
||||
{
|
||||
"id": "css-animations-pro",
|
||||
"title": "CSS Animations Pro",
|
||||
"goal": "Bring your designs to life with smooth transitions and powerful keyframe animations. Learn timing functions, transform properties, and how to create engaging interactive experiences.",
|
||||
"estimatedTime": 60,
|
||||
"difficulty": "advanced",
|
||||
"modules": [
|
||||
"transitions-animations"
|
||||
],
|
||||
"prerequisites": ["css-fundamentals", "flexbox-master"]
|
||||
}
|
||||
]
|
||||
@@ -3,10 +3,15 @@
|
||||
* Supports English and German lesson content
|
||||
*/
|
||||
|
||||
// Learning paths import
|
||||
import learningPathsConfig from "../../src/config/learning-paths.json";
|
||||
|
||||
// English lesson imports
|
||||
import welcomeEN from "../../lessons/00-welcome.json";
|
||||
import basicSelectorsEN from "../../lessons/00-basic-selectors.json";
|
||||
import boxModelEN from "../../lessons/01-box-model.json";
|
||||
import colorsBackgroundsEN from "../../lessons/03-colors.json";
|
||||
import typographyFontsEN from "../../lessons/04-typography.json";
|
||||
import unitsVariablesEN from "../../lessons/05-units-variables.json";
|
||||
import transitionsAnimationsEN from "../../lessons/06-transitions-animations.json";
|
||||
import responsiveEN from "../../lessons/08-responsive.json";
|
||||
@@ -15,6 +20,8 @@ import htmlFormsBasicEN from "../../lessons/21-html-forms-basic.json";
|
||||
import htmlFormsValidationEN from "../../lessons/22-html-forms-validation.json";
|
||||
import htmlDetailsSummaryEN from "../../lessons/23-html-details-summary.json";
|
||||
import htmlProgressMeterEN from "../../lessons/24-html-progress-meter.json";
|
||||
import htmlDatalistEN from "../../lessons/25-html-datalist.json";
|
||||
import htmlFormsFieldsetEN from "../../lessons/28-html-forms-fieldset.json";
|
||||
import htmlTablesEN from "../../lessons/30-html-tables.json";
|
||||
import htmlMarqueeEN from "../../lessons/31-html-marquee.json";
|
||||
import htmlSvgEN from "../../lessons/32-html-svg.json";
|
||||
@@ -114,6 +121,8 @@ const moduleStoreEN = [
|
||||
htmlElementsEN,
|
||||
htmlFormsBasicEN,
|
||||
htmlFormsValidationEN,
|
||||
htmlFormsFieldsetEN,
|
||||
htmlDatalistEN,
|
||||
// HTML Interaktiv
|
||||
htmlDetailsSummaryEN,
|
||||
htmlProgressMeterEN,
|
||||
@@ -124,6 +133,8 @@ const moduleStoreEN = [
|
||||
// CSS Grundlagen
|
||||
basicSelectorsEN,
|
||||
boxModelEN,
|
||||
colorsBackgroundsEN,
|
||||
typographyFontsEN,
|
||||
unitsVariablesEN,
|
||||
// CSS Layouts
|
||||
flexboxEN,
|
||||
@@ -347,3 +358,77 @@ export function addCustomModule(moduleConfig, language = "en") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a learning path configuration
|
||||
* @param {Object} path - The learning path configuration to validate
|
||||
* @throws {Error} If the configuration is invalid
|
||||
*/
|
||||
function validateLearningPath(path) {
|
||||
// Required fields
|
||||
if (!path.id) throw new Error('Learning path missing "id"');
|
||||
if (!path.title) throw new Error('Learning path missing "title"');
|
||||
if (!path.goal) throw new Error('Learning path missing "goal"');
|
||||
if (typeof path.estimatedTime !== "number" || path.estimatedTime < 1) {
|
||||
throw new Error('Learning path missing valid "estimatedTime"');
|
||||
}
|
||||
if (!["beginner", "intermediate", "advanced"].includes(path.difficulty)) {
|
||||
throw new Error('Learning path has invalid "difficulty"');
|
||||
}
|
||||
if (!Array.isArray(path.modules) || path.modules.length === 0) {
|
||||
throw new Error('Learning path missing "modules" array or array is empty');
|
||||
}
|
||||
|
||||
// Validate module IDs format
|
||||
path.modules.forEach((moduleId, index) => {
|
||||
if (typeof moduleId !== "string" || !/^[a-z0-9-]+$/.test(moduleId)) {
|
||||
throw new Error(`Module ${index} has invalid ID format: ${moduleId}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Validate prerequisites if present
|
||||
if (path.prerequisites && !Array.isArray(path.prerequisites)) {
|
||||
throw new Error('Learning path "prerequisites" must be an array');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and validate learning paths with resolved module references
|
||||
* @param {string} language - Language code ('en', 'de', 'pl', 'es', 'ar', 'uk')
|
||||
* @returns {Array} Array of learning paths with resolved module objects
|
||||
*/
|
||||
export function loadLearningPaths(language = "en") {
|
||||
try {
|
||||
// Validate each path
|
||||
learningPathsConfig.forEach((path) => {
|
||||
validateLearningPath(path);
|
||||
});
|
||||
|
||||
// Get the appropriate module store for the language
|
||||
const modules = loadModules(language);
|
||||
|
||||
// Resolve module references to actual module objects
|
||||
const resolvedPaths = learningPathsConfig.map((path) => {
|
||||
const resolvedModules = path.modules
|
||||
.map((moduleId) => {
|
||||
const module = modules.find((m) => m.id === moduleId);
|
||||
if (!module) {
|
||||
console.warn(`Module "${moduleId}" not found for path "${path.id}"`);
|
||||
return null;
|
||||
}
|
||||
return module;
|
||||
})
|
||||
.filter((module) => module !== null);
|
||||
|
||||
return {
|
||||
...path,
|
||||
modules: resolvedModules
|
||||
};
|
||||
});
|
||||
|
||||
return resolvedPaths;
|
||||
} catch (error) {
|
||||
console.error("Error loading learning paths:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export class LessonEngine {
|
||||
this.modules = [];
|
||||
this.userProgress = {}; // Format: { moduleId: { completed: [0, 2, 3], current: 4 } }
|
||||
this.userCodeMap = new Map(); // Store user code for each lesson
|
||||
this.pathManager = null; // Optional PathManager for guided learning paths
|
||||
this.loadUserProgress();
|
||||
}
|
||||
|
||||
@@ -26,6 +27,14 @@ export class LessonEngine {
|
||||
this.loadUserCodeFromStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the PathManager for guided learning paths
|
||||
* @param {PathManager} pathManager - The PathManager instance
|
||||
*/
|
||||
setPathManager(pathManager) {
|
||||
this.pathManager = pathManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current module
|
||||
* @param {Object} module - The module object from the config
|
||||
@@ -102,9 +111,29 @@ export class LessonEngine {
|
||||
|
||||
/**
|
||||
* Move to the next lesson (crosses module boundaries)
|
||||
* When a path is active, follows path order instead of module order
|
||||
* @returns {boolean} Whether the operation was successful
|
||||
*/
|
||||
nextLesson() {
|
||||
// If PathManager is set and has an active path, use path order
|
||||
if (this.pathManager) {
|
||||
const activePath = this.pathManager.getActivePath();
|
||||
if (activePath) {
|
||||
const nextLesson = this.pathManager.getNextLesson(activePath.id);
|
||||
if (nextLesson) {
|
||||
// Navigate to the next lesson in the path
|
||||
const success = this.setModuleById(nextLesson.moduleId);
|
||||
if (success) {
|
||||
return this.setLessonByIndex(nextLesson.lessonIndex);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Path is complete, no next lesson
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Default behavior: follow module order
|
||||
// Try next lesson in current module
|
||||
if (this.setLessonByIndex(this.currentLessonIndex + 1)) {
|
||||
return true;
|
||||
@@ -404,6 +433,11 @@ export class LessonEngine {
|
||||
moduleProgress.completed.push(this.currentLessonIndex);
|
||||
this.saveUserProgress();
|
||||
}
|
||||
|
||||
// Also notify PathManager if a path is active
|
||||
if (this.pathManager && this.pathManager.getActivePath()) {
|
||||
this.pathManager.markLessonCompleted(this.currentModule.id, this.currentLessonIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
287
src/impl/PathManager.js
Normal file
287
src/impl/PathManager.js
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* PathManager - Manages learning path state, progress tracking, and persistence
|
||||
* Handles active path selection, lesson completion tracking, and localStorage sync
|
||||
*/
|
||||
export class PathManager {
|
||||
constructor() {
|
||||
this.activePathId = null;
|
||||
this.pathProgress = {}; // Format: { pathId: { completedLessons: [], startTimestamp: ISO, lastActivityTimestamp: ISO } }
|
||||
this.paths = [];
|
||||
this.loadPathProgress();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize with learning paths array
|
||||
* @param {Array} paths - Available learning paths
|
||||
*/
|
||||
setPaths(paths) {
|
||||
this.paths = paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new learning path
|
||||
* @param {string} pathId - The learning path ID to start
|
||||
* @returns {boolean} Whether the operation was successful
|
||||
*/
|
||||
startPath(pathId) {
|
||||
const path = this.paths.find((p) => p.id === pathId);
|
||||
if (!path) return false;
|
||||
|
||||
this.activePathId = pathId;
|
||||
|
||||
// Initialize progress if not exists
|
||||
if (!this.pathProgress[pathId]) {
|
||||
this.pathProgress[pathId] = {
|
||||
completedLessons: [],
|
||||
startTimestamp: new Date().toISOString(),
|
||||
lastActivityTimestamp: new Date().toISOString()
|
||||
};
|
||||
} else {
|
||||
// Update last activity timestamp
|
||||
this.pathProgress[pathId].lastActivityTimestamp = new Date().toISOString();
|
||||
}
|
||||
|
||||
this.savePathProgress();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the currently active path
|
||||
* @returns {boolean} Whether the operation was successful
|
||||
*/
|
||||
pausePath() {
|
||||
if (!this.activePathId) return false;
|
||||
|
||||
// Update last activity timestamp before pausing
|
||||
if (this.pathProgress[this.activePathId]) {
|
||||
this.pathProgress[this.activePathId].lastActivityTimestamp = new Date().toISOString();
|
||||
}
|
||||
|
||||
this.activePathId = null;
|
||||
this.savePathProgress();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a previously started path
|
||||
* @param {string} pathId - The learning path ID to resume
|
||||
* @returns {boolean} Whether the operation was successful
|
||||
*/
|
||||
resumePath(pathId) {
|
||||
const path = this.paths.find((p) => p.id === pathId);
|
||||
if (!path) return false;
|
||||
|
||||
// Can only resume a path that has been started
|
||||
if (!this.pathProgress[pathId]) return false;
|
||||
|
||||
this.activePathId = pathId;
|
||||
|
||||
// Update last activity timestamp
|
||||
this.pathProgress[pathId].lastActivityTimestamp = new Date().toISOString();
|
||||
|
||||
this.savePathProgress();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently active path
|
||||
* @returns {Object|null} The active path object or null
|
||||
*/
|
||||
getActivePath() {
|
||||
if (!this.activePathId) return null;
|
||||
return this.paths.find((p) => p.id === this.activePathId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a lesson as completed in the active path
|
||||
* @param {string} moduleId - The module ID
|
||||
* @param {number} lessonIndex - The lesson index
|
||||
*/
|
||||
markLessonCompleted(moduleId, lessonIndex) {
|
||||
if (!this.activePathId) return;
|
||||
|
||||
const lessonKey = `${moduleId}-${lessonIndex}`;
|
||||
const progress = this.pathProgress[this.activePathId];
|
||||
|
||||
if (progress && !progress.completedLessons.includes(lessonKey)) {
|
||||
progress.completedLessons.push(lessonKey);
|
||||
progress.lastActivityTimestamp = new Date().toISOString();
|
||||
this.savePathProgress();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a lesson is completed in the active path
|
||||
* @param {string} moduleId - The module ID
|
||||
* @param {number} lessonIndex - The lesson index
|
||||
* @returns {boolean} Whether the lesson is completed
|
||||
*/
|
||||
isLessonCompleted(moduleId, lessonIndex) {
|
||||
if (!this.activePathId) return false;
|
||||
|
||||
const lessonKey = `${moduleId}-${lessonIndex}`;
|
||||
const progress = this.pathProgress[this.activePathId];
|
||||
|
||||
return progress && progress.completedLessons.includes(lessonKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress for a specific path
|
||||
* @param {string} pathId - The learning path ID
|
||||
* @returns {Object} Progress information
|
||||
*/
|
||||
getPathProgress(pathId) {
|
||||
const path = this.paths.find((p) => p.id === pathId);
|
||||
if (!path) return null;
|
||||
|
||||
const progress = this.pathProgress[pathId] || {
|
||||
completedLessons: [],
|
||||
startTimestamp: null,
|
||||
lastActivityTimestamp: null
|
||||
};
|
||||
|
||||
// Calculate total lessons in path
|
||||
let totalLessons = 0;
|
||||
path.modules.forEach((module) => {
|
||||
if (module.lessons) {
|
||||
totalLessons += module.lessons.length;
|
||||
}
|
||||
});
|
||||
|
||||
const completedCount = progress.completedLessons.length;
|
||||
const percentComplete = totalLessons > 0 ? Math.round((completedCount / totalLessons) * 100) : 0;
|
||||
|
||||
return {
|
||||
pathId,
|
||||
completedLessons: progress.completedLessons,
|
||||
completedCount,
|
||||
totalLessons,
|
||||
percentComplete,
|
||||
startTimestamp: progress.startTimestamp,
|
||||
lastActivityTimestamp: progress.lastActivityTimestamp,
|
||||
isStarted: progress.startTimestamp !== null,
|
||||
isComplete: completedCount >= totalLessons && totalLessons > 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next incomplete lesson in a path
|
||||
* @param {string} pathId - The learning path ID
|
||||
* @returns {Object|null} Next lesson info { moduleId, lessonIndex } or null
|
||||
*/
|
||||
getNextLesson(pathId) {
|
||||
const path = this.paths.find((p) => p.id === pathId);
|
||||
if (!path) return null;
|
||||
|
||||
// Iterate through all modules in the path
|
||||
for (const module of path.modules) {
|
||||
if (!module.lessons) continue;
|
||||
|
||||
// Check each lesson in the module
|
||||
for (let lessonIndex = 0; lessonIndex < module.lessons.length; lessonIndex++) {
|
||||
const lessonKey = `${module.id}-${lessonIndex}`;
|
||||
const progress = this.pathProgress[pathId];
|
||||
|
||||
// Found an incomplete lesson
|
||||
if (!progress || !progress.completedLessons.includes(lessonKey)) {
|
||||
return {
|
||||
moduleId: module.id,
|
||||
lessonIndex
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All lessons completed
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is complete
|
||||
* @param {string} pathId - The learning path ID
|
||||
* @returns {boolean} Whether the path is complete
|
||||
*/
|
||||
isPathComplete(pathId) {
|
||||
const progress = this.getPathProgress(pathId);
|
||||
return progress ? progress.isComplete : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate estimated time remaining for a path
|
||||
* @param {string} pathId - The learning path ID
|
||||
* @returns {number} Estimated minutes remaining
|
||||
*/
|
||||
calculateEstimatedTimeRemaining(pathId) {
|
||||
const path = this.paths.find((p) => p.id === pathId);
|
||||
if (!path) return 0;
|
||||
|
||||
const progress = this.getPathProgress(pathId);
|
||||
if (!progress) return path.estimatedTime || 0;
|
||||
|
||||
// Calculate remaining time based on completion percentage
|
||||
const remainingPercent = 100 - progress.percentComplete;
|
||||
const estimatedRemaining = Math.round((path.estimatedTime * remainingPercent) / 100);
|
||||
|
||||
return estimatedRemaining;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all paths with their progress information
|
||||
* @returns {Array} Array of paths with progress data
|
||||
*/
|
||||
getAllPathsWithProgress() {
|
||||
return this.paths.map((path) => ({
|
||||
...path,
|
||||
progress: this.getPathProgress(path.id)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Save path progress to localStorage
|
||||
*/
|
||||
savePathProgress() {
|
||||
try {
|
||||
const progressData = {
|
||||
activePathId: this.activePathId,
|
||||
pathProgress: this.pathProgress,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
localStorage.setItem("codeCrispies.pathProgress", JSON.stringify(progressData));
|
||||
} catch (e) {
|
||||
console.error("Error saving path progress:", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load path progress from localStorage
|
||||
* @returns {Object|null} Loaded progress metadata or null
|
||||
*/
|
||||
loadPathProgress() {
|
||||
try {
|
||||
const savedProgress = localStorage.getItem("codeCrispies.pathProgress");
|
||||
if (savedProgress) {
|
||||
const progressData = JSON.parse(savedProgress);
|
||||
|
||||
this.activePathId = progressData.activePathId || null;
|
||||
this.pathProgress = progressData.pathProgress || {};
|
||||
|
||||
return {
|
||||
activePathId: this.activePathId,
|
||||
timestamp: progressData.timestamp
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error loading path progress:", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all path progress and active state
|
||||
*/
|
||||
clearProgress() {
|
||||
this.activePathId = null;
|
||||
this.pathProgress = {};
|
||||
localStorage.removeItem("codeCrispies.pathProgress");
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,10 @@
|
||||
<h1><span class="code-text">CODE</span><span>CRISPIES</span></h1>
|
||||
</a>
|
||||
<div class="header-actions">
|
||||
<button id="path-indicator" class="path-indicator" style="display: none;" aria-label="Current learning path">
|
||||
<span class="path-indicator-name"></span>
|
||||
<span class="path-indicator-progress"></span>
|
||||
</button>
|
||||
<button id="help-btn" class="help-toggle" data-i18n-aria-label="help" aria-label="Help">?</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -81,6 +85,7 @@
|
||||
<span class="level-indicator" id="level-indicator"></span>
|
||||
</span>
|
||||
<button id="next-btn" class="btn btn-primary" data-i18n="next">Next</button>
|
||||
<button id="next-in-path-btn" class="btn btn-path" style="display: none;" data-i18n="nextInPath">Next in Path</button>
|
||||
</div>
|
||||
<div class="preview-section">
|
||||
<div class="preview-wrapper">
|
||||
@@ -122,6 +127,20 @@
|
||||
<div class="module-list" id="module-list" role="tree" aria-labelledby="lessons-heading"></div>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<h4 data-i18n="learningPaths">Learning Paths</h4>
|
||||
<div id="path-progress-display" class="path-progress-display" style="display: none;">
|
||||
<div class="path-progress-info">
|
||||
<div class="path-progress-name"></div>
|
||||
<div class="path-progress-stats"></div>
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="path-progress-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="view-paths-btn" class="btn btn-text" data-i18n="viewAllPaths">View All Paths</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<h4 data-i18n="settings">Settings</h4>
|
||||
<label class="setting-row">
|
||||
@@ -262,8 +281,66 @@
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Learning Paths Dialog -->
|
||||
<dialog id="paths-dialog" class="dialog">
|
||||
<div class="dialog-header">
|
||||
<h3 data-i18n="learningPathsTitle">Learning Paths</h3>
|
||||
<button id="paths-dialog-close" class="dialog-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="dialog-content">
|
||||
<p data-i18n="learningPathsDescription">
|
||||
Choose a guided learning path to help you reach your goals. Each path includes a curated sequence of lessons.
|
||||
</p>
|
||||
<div id="paths-list" class="paths-list" role="list">
|
||||
<!-- Path cards will be dynamically inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Path Completion Celebration Dialog -->
|
||||
<dialog id="path-completion-dialog" class="dialog celebration-dialog">
|
||||
<div class="dialog-header">
|
||||
<h3 data-i18n="pathCompletionTitle">🎉 Path Complete!</h3>
|
||||
<button id="path-completion-dialog-close" class="dialog-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="dialog-content">
|
||||
<p class="celebration-message" data-i18n="pathCompletionMessage">Congratulations! You've completed this learning path.</p>
|
||||
|
||||
<div class="completion-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-icon">📚</span>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label" data-i18n="lessonsCompleted">Lessons Completed</span>
|
||||
<span class="stat-value" id="completion-lessons-count">0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-icon">⏱️</span>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label" data-i18n="timeTaken">Time Taken</span>
|
||||
<span class="stat-value" id="completion-time-taken">0 min</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="next-path-suggestion" id="next-path-suggestion" style="display: none;">
|
||||
<p class="suggestion-label" data-i18n="recommendedNextPath">Recommended next path:</p>
|
||||
<div class="suggested-path-card">
|
||||
<h4 id="suggested-path-title"></h4>
|
||||
<p id="suggested-path-goal"></p>
|
||||
<button id="start-suggested-path-btn" class="btn btn-primary" data-i18n="startThisPath">Start This Path</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button id="view-all-paths-from-completion" class="btn" data-i18n="viewAllPaths">View All Paths</button>
|
||||
<button id="close-completion-dialog" class="btn btn-ghost" data-i18n="continueLearning">Continue Learning</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
849
src/main.css
849
src/main.css
@@ -221,6 +221,44 @@ kbd {
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Path Indicator Pill */
|
||||
.path-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--primary-bg-medium);
|
||||
color: var(--primary-color);
|
||||
padding: 6px 12px;
|
||||
border-radius: 16px;
|
||||
border: none;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.path-indicator:hover {
|
||||
background: var(--primary-bg-light);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(94, 75, 139, 0.15);
|
||||
}
|
||||
|
||||
.path-indicator-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.path-indicator-progress {
|
||||
color: var(--primary-dark);
|
||||
font-weight: 700;
|
||||
opacity: 0.9;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* ================= GAME LAYOUT ================= */
|
||||
.game-layout {
|
||||
display: flex;
|
||||
@@ -886,770 +924,153 @@ nav.sidebar-section {
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--success-color);
|
||||
border-radius: 4px;
|
||||
background: var(--primary-color);
|
||||
transition: width 0.3s ease;
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--light-text);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Module List in Sidebar */
|
||||
.module-list {
|
||||
/* No max-height - parent nav.sidebar-section handles overflow */
|
||||
}
|
||||
|
||||
.module-container {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.module-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.module-header:hover {
|
||||
background: var(--primary-bg-light);
|
||||
}
|
||||
|
||||
.module-header.completed::before {
|
||||
content: "✓";
|
||||
margin-right: 6px;
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 10px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.lessons-container {
|
||||
margin-left: 16px;
|
||||
border-left: 2px solid var(--border-color);
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.lesson-list-item {
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
margin: 2px 0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.lesson-list-item:hover {
|
||||
background: var(--primary-bg-light);
|
||||
}
|
||||
|
||||
.lesson-list-item.active {
|
||||
background: var(--primary-bg-medium);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lesson-list-item.completed::before {
|
||||
content: "✓";
|
||||
margin-right: 6px;
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
/* Sidebar focus styles - enhance visibility without overriding defaults */
|
||||
.module-header:focus,
|
||||
.lesson-list-item:focus {
|
||||
background: var(--primary-bg-light);
|
||||
}
|
||||
|
||||
/* Button reset for sidebar items (when converted to buttons) */
|
||||
button.module-header,
|
||||
button.lesson-list-item {
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* ================= BUTTONS ================= */
|
||||
.btn {
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
border-radius: var(--border-radius-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--panel-bg);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-main);
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
.btn img {
|
||||
width: 0.8rem;
|
||||
height: 0.8rem;
|
||||
margin-right: 0.3rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-color);
|
||||
color: var(--white-text);
|
||||
border-color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.btn-run {
|
||||
background: var(--secondary-color);
|
||||
color: var(--white-text);
|
||||
border-color: var(--secondary-dark);
|
||||
}
|
||||
|
||||
.btn-run:hover {
|
||||
background: var(--secondary-dark);
|
||||
}
|
||||
|
||||
.btn-run.success {
|
||||
background: var(--success-color);
|
||||
border-color: var(--success-color-dark);
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 4px 10px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 4px 8px;
|
||||
font-size: 1rem;
|
||||
min-width: 32px;
|
||||
background: transparent;
|
||||
color: var(--light-text);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.editor-tools {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--light-text);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: var(--bg-color);
|
||||
color: var(--danger-color);
|
||||
border-color: var(--danger-color);
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
background: transparent;
|
||||
color: var(--light-text);
|
||||
border: none;
|
||||
font-size: 0.85rem;
|
||||
text-decoration: underline;
|
||||
padding: var(--spacing-xs) 0;
|
||||
}
|
||||
|
||||
.btn-text:hover {
|
||||
background: transparent;
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
#reset-code-btn {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
#reset-code-btn:hover {
|
||||
background: var(--primary-dark);
|
||||
border-color: var(--primary-dark);
|
||||
}
|
||||
|
||||
/* Hide Run button - live preview is stable */
|
||||
#run-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ================= TOGGLE SWITCH ================= */
|
||||
/* Setting row (for label + control) */
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Language select */
|
||||
.lang-select {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel-bg);
|
||||
color: var(--text-color);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.lang-select:hover {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.lang-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
background: #ccc;
|
||||
border-radius: 20px;
|
||||
transition: 0.3s;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-slider::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
input:checked + .toggle-slider {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
input:checked + .toggle-slider::before {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* ================= DIALOG (Native HTML) ================= */
|
||||
.dialog {
|
||||
border: none;
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-modal);
|
||||
padding: 0;
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
background: var(--panel-bg);
|
||||
/* Ensure centering - native dialog should center, but explicit for safety */
|
||||
margin: auto;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.dialog::backdrop {
|
||||
background: var(--modal-bg);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.dialog-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dialog-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--light-text);
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.dialog-close:hover {
|
||||
background: var(--primary-bg-light);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.dialog-content h4 {
|
||||
margin-top: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
color: var(--dark-text);
|
||||
}
|
||||
|
||||
.dialog-content h4:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.dialog-content p {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.dialog-content ul,
|
||||
.dialog-content ol {
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
padding-left: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.dialog-content li {
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.dialog-content kbd {
|
||||
background: var(--code-bg);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 0.85em;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* Project Cards in Help Dialog */
|
||||
.project-cards {
|
||||
/* Lesson list nav */
|
||||
.lesson-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-md);
|
||||
gap: var(--spacing-xs);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
display: block;
|
||||
padding: var(--spacing-md);
|
||||
background: var(--primary-bg-light);
|
||||
border-radius: var(--border-radius-md);
|
||||
border: 1px solid var(--primary-bg-medium);
|
||||
text-decoration: none;
|
||||
.lesson-item {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-color);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.project-card:hover {
|
||||
background: var(--primary-bg-medium);
|
||||
border-color: var(--primary-color);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(94, 75, 139, 0.15);
|
||||
}
|
||||
|
||||
.project-card strong {
|
||||
display: block;
|
||||
color: var(--primary-color);
|
||||
font-size: 1rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.project-card span {
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.9rem;
|
||||
color: var(--light-text);
|
||||
line-height: 1.4;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
/* ================= FOOTER ================= */
|
||||
.app-footer {
|
||||
padding: var(--spacing-md);
|
||||
font-size: 0.7rem;
|
||||
color: var(--light-text);
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.app-footer a {
|
||||
.lesson-item:hover {
|
||||
background: var(--primary-bg-light);
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ================= UTILITY ================= */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
.lesson-item.active {
|
||||
background: var(--primary-bg-medium);
|
||||
color: var(--primary-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lesson-item.completed::before {
|
||||
content: "✓";
|
||||
color: var(--success-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* ================= RESPONSIVE ================= */
|
||||
@media (max-width: 768px) {
|
||||
.game-layout {
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
@media (max-width: 1024px) {
|
||||
.left-panel {
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
width: 60%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.left-panel,
|
||||
.right-panel {
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
border-right: none;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* Mobile order: nav -> instructions -> preview -> editor */
|
||||
.game-controls {
|
||||
order: 1;
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.instructions {
|
||||
order: 2;
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
order: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
.preview-section .preview-header {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.preview-section .preview-wrapper {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.editor-section {
|
||||
order: 4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
.preview-wrapper {
|
||||
flex: 1;
|
||||
margin: var(--spacing-sm);
|
||||
min-height: 40vh;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
flex: 1;
|
||||
min-height: 45vh;
|
||||
}
|
||||
|
||||
.module-pill {
|
||||
flex: 1;
|
||||
margin: 0 var(--spacing-sm);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.module-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.level-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
font-size: 0.85rem;
|
||||
.game-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Concept section mobile adjustments */
|
||||
.concept-section {
|
||||
margin-bottom: var(--spacing-md);
|
||||
.editor-section {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.concept-diagram {
|
||||
padding: var(--spacing-sm);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
/* Enable horizontal scrolling with better mobile UX */
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
/* Add visual hint that content is scrollable */
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--panel-bg) 0%,
|
||||
var(--panel-bg) calc(100% - 20px),
|
||||
rgba(94, 75, 139, 0.05) 100%
|
||||
);
|
||||
.preview-section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.concept-container-vs-item {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
font-size: 0.8rem;
|
||||
.menu-toggle {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header {
|
||||
padding: 0 var(--spacing-sm);
|
||||
}
|
||||
|
||||
.preview-wrapper {
|
||||
margin: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
#lesson-title {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.lesson-description {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.task-instruction {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.code-input {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Concept section small mobile adjustments */
|
||||
.concept-explanation {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.concept-diagram {
|
||||
padding: var(--spacing-xs);
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.25;
|
||||
/* Smaller border radius on mobile */
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.concept-container-vs-item {
|
||||
padding: var(--spacing-xs);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.concept-summary {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
.instructions {
|
||||
max-height: calc(60vh - 60px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ================== RTL SUPPORT ================== */
|
||||
|
||||
/* RTL: Sidebar slides from right */
|
||||
[dir="rtl"] .sidebar-drawer {
|
||||
left: auto;
|
||||
right: calc(-1 * var(--sidebar-width));
|
||||
transition: right 0.3s ease;
|
||||
/* ================= ANIMATIONS ================= */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
[dir="rtl"] .sidebar-drawer.open {
|
||||
right: 0;
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(94, 75, 139, 0.4);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(94, 75, 139, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(94, 75, 139, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* RTL: Content shifts to left when sidebar opens */
|
||||
[dir="rtl"] .app-container:has(.sidebar-drawer.open) .game-layout {
|
||||
transform: translateX(calc(-1 * var(--sidebar-width) * 0.8));
|
||||
/* ================= UTILITY CLASSES ================= */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* RTL: Flip horizontal layouts */
|
||||
[dir="rtl"] .header-left,
|
||||
[dir="rtl"] .header-right {
|
||||
flex-direction: row-reverse;
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
/* RTL: Editor tools */
|
||||
[dir="rtl"] .editor-tools {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
/* RTL: Navigation buttons */
|
||||
[dir="rtl"] .nav-controls {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
/* RTL: Hint layout */
|
||||
[dir="rtl"] .hint {
|
||||
flex-direction: row-reverse;
|
||||
text-align: right;
|
||||
border-left: none;
|
||||
border-right: 3px solid var(--primary-light);
|
||||
}
|
||||
|
||||
[dir="rtl"] .hint-success {
|
||||
border-right-color: var(--success-color);
|
||||
}
|
||||
|
||||
/* RTL: Module list items */
|
||||
[dir="rtl"] .module-header {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
[dir="rtl"] .lesson-list button {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* RTL: Lesson progress indicator */
|
||||
[dir="rtl"] .lesson-list button::before {
|
||||
margin-left: var(--spacing-sm);
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
/* RTL: Content areas - use auto direction for mixed content */
|
||||
[dir="rtl"] .lesson-description,
|
||||
[dir="rtl"] .task-instruction {
|
||||
direction: auto;
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
|
||||
/* RTL: Code editor always LTR */
|
||||
[dir="rtl"] .editor-content,
|
||||
[dir="rtl"] .CodeMirror {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* RTL: Preview always LTR (code output) */
|
||||
[dir="rtl"] .preview-wrapper,
|
||||
[dir="rtl"] #preview-area {
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
/* RTL: Dialog close button */
|
||||
[dir="rtl"] .dialog-close {
|
||||
left: var(--spacing-sm);
|
||||
right: auto;
|
||||
}
|
||||
|
||||
/* RTL: Keep logo in LTR order */
|
||||
[dir="rtl"] .logo {
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
/* RTL: Swap left/right panels */
|
||||
[dir="rtl"] .game-layout {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
/* RTL: Left panel border flips to left side */
|
||||
[dir="rtl"] .left-panel {
|
||||
border-right: none;
|
||||
border-left: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* RTL: Lessons container indentation flips */
|
||||
[dir="rtl"] .lessons-container {
|
||||
margin-left: 0;
|
||||
margin-right: 16px;
|
||||
border-left: none;
|
||||
border-right: 2px solid var(--border-color);
|
||||
padding-left: 0;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
/* RTL: Module expand icon */
|
||||
[dir="rtl"] .module-header .expand-icon {
|
||||
margin-left: 6px;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
/* RTL: Lesson checkmark position */
|
||||
[dir="rtl"] .lesson-list-item::before {
|
||||
margin-left: 6px;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
/* RTL: Toggle switch slider */
|
||||
[dir="rtl"] .toggle-slider {
|
||||
margin-right: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* RTL: Setting row */
|
||||
[dir="rtl"] .setting-row {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
/* RTL: Preview controls */
|
||||
[dir="rtl"] .preview-controls {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
/* RTL: Concept section */
|
||||
[dir="rtl"] .concept-section {
|
||||
border-left: 1px solid var(--primary-bg-medium);
|
||||
border-right: 3px solid var(--primary-color);
|
||||
}
|
||||
|
||||
[dir="rtl"] .concept-summary {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
[dir="rtl"] .concept-container-vs-item {
|
||||
border-left: 1px solid var(--success-bg-light);
|
||||
border-right: 3px solid var(--success-color);
|
||||
}
|
||||
.no-scroll {
|
||||
overflow: hidden;
|
||||
}
|
||||
Reference in New Issue
Block a user