feat: add lesson difficulty indicators and improve mobile sidebar

- Add computeLessonDifficulty function to determine lesson difficulty
  based on selector complexity (easy/medium/hard)
- Display difficulty badge with bar indicator in lesson title row
- Add mobile navigation links (CSS, HTML, Tailwind) to sidebar
- Add mobile auth trigger button in sidebar
- Redesign settings section with card layout and native toggles
- Add difficulty translations for all 6 languages
- Fix module pill overflow on narrow screens
This commit is contained in:
2026-01-16 21:47:47 +01:00
parent d5bd23615f
commit cf0d2cba51
7 changed files with 397 additions and 30 deletions

View File

@@ -1,6 +1,6 @@
import { LessonEngine } from "./impl/LessonEngine.js";
import { CodeEditor, crispyEditorTheme } from "./impl/CodeEditor.js";
import { renderLesson, renderModuleList, renderLevelIndicator, updateActiveLessonInSidebar } from "./helpers/renderer.js";
import { renderLesson, renderModuleList, renderLevelIndicator, updateActiveLessonInSidebar, renderDifficultyBadge } from "./helpers/renderer.js";
import { loadModules } from "./config/lessons.js";
import { initI18n, t, getLanguage, setLanguage, applyTranslations } from "./i18n.js";
import { parseHash, updateHash, replaceHash, getShareableUrl, RouteType, navigateTo } from "./helpers/router.js";
@@ -648,6 +648,9 @@ function loadCurrentLesson() {
lesson
);
// Render difficulty badge
renderDifficultyBadge(elements.lessonTitleRow, lesson);
// Set user code in CodeMirror (clear history to prevent undo/redo across lessons)
if (codeEditor) {
codeEditor.setValueAndClearHistory(engineState.userCode);
@@ -657,12 +660,13 @@ function loadCurrentLesson() {
if (engineState.isCompleted) {
elements.runBtn.querySelector("span").textContent = t("rerun");
// Add completion badge if not present
if (!document.querySelector(".completion-badge")) {
// Add completion badge to difficulty-wrapper if not present
const wrapper = document.querySelector(".difficulty-wrapper");
if (wrapper && !wrapper.querySelector(".completion-badge")) {
const badge = document.createElement("span");
badge.className = "completion-badge";
badge.textContent = t("completed");
elements.lessonTitleRow.appendChild(badge);
wrapper.appendChild(badge);
}
// Show gradient border and glow for completed lessons
@@ -671,7 +675,7 @@ function loadCurrentLesson() {
} else {
elements.runBtn.querySelector("span").textContent = t("run");
// Remove completion badge and border if exists
// Remove completion badge if exists
const badge = document.querySelector(".completion-badge");
if (badge) badge.remove();
elements.previewWrapper?.classList.remove("completed-glow");
@@ -2480,6 +2484,11 @@ function init() {
elements.closeSidebar.addEventListener("click", closeSidebar);
elements.sidebarBackdrop.addEventListener("click", closeSidebar);
// Sidebar nav links (mobile) - close sidebar on click
document.querySelectorAll(".sidebar-nav-link").forEach((link) => {
link.addEventListener("click", closeSidebar);
});
// Logo click - navigate to home landing
elements.logoLink.addEventListener("click", (e) => {
e.preventDefault();

View File

@@ -153,6 +153,7 @@ function updateAuthUI(user) {
// Sidebar elements
const authTriggerSidebar = document.getElementById("auth-trigger-sidebar");
const authTriggerMobile = document.getElementById("auth-trigger-mobile");
const userMenuSidebar = document.getElementById("user-menu-sidebar");
const userEmailSidebar = document.getElementById("user-email-sidebar");
const sidebarHint = document.querySelector(".sidebar-auth-hint");
@@ -161,6 +162,7 @@ function updateAuthUI(user) {
authTriggerHeader?.classList.add("hidden");
userEmailHeader?.classList.remove("hidden");
authTriggerSidebar?.classList.add("hidden");
authTriggerMobile?.classList.add("hidden");
userMenuSidebar?.classList.remove("hidden");
sidebarHint?.classList.add("hidden");
if (userEmailHeader) userEmailHeader.textContent = user.email;
@@ -169,6 +171,7 @@ function updateAuthUI(user) {
authTriggerHeader?.classList.remove("hidden");
userEmailHeader?.classList.add("hidden");
authTriggerSidebar?.classList.remove("hidden");
authTriggerMobile?.classList.remove("hidden");
userMenuSidebar?.classList.add("hidden");
sidebarHint?.classList.remove("hidden");
}
@@ -257,7 +260,7 @@ function setupAuthForms() {
.getElementById("show-reset")
?.addEventListener("click", () => switchForm("reset"));
// Dialog triggers (both header and sidebar)
// Dialog triggers (header, sidebar, and mobile)
document
.getElementById("auth-trigger-header")
?.addEventListener("click", () => {
@@ -268,6 +271,11 @@ function setupAuthForms() {
?.addEventListener("click", () => {
authDialog?.showModal();
});
document
.getElementById("auth-trigger-mobile")
?.addEventListener("click", () => {
authDialog?.showModal();
});
// Logout button (sidebar only)
document

View File

@@ -3,6 +3,49 @@
*/
import { t } from "../i18n.js";
/**
* Compute lesson difficulty based on lesson structure
* - Easy: selector is provided in codePrefix (student only writes properties)
* - Medium: student writes a simple selector (single element/class)
* - Hard: student writes compound selectors (descendant, chained classes, type+class)
* @param {Object} lesson - The lesson object
* @returns {"easy"|"medium"|"hard"} The computed difficulty
*/
export function computeLessonDifficulty(lesson) {
const codePrefix = lesson.codePrefix || "";
const solution = lesson.solution || "";
// If codePrefix contains an opening brace, selector is provided → Easy
if (codePrefix.includes("{")) {
return "easy";
}
// No codePrefix with selector - check the solution complexity
// Hard: descendant selectors (space before {), chained classes (.a.b), type+class (a.class)
const selectorMatch = solution.match(/^([^{]+)\{/);
if (selectorMatch) {
const selector = selectorMatch[1].trim();
// Descendant selector: has space (e.g., ".nav a", ".card p")
if (/\S\s+\S/.test(selector)) {
return "hard";
}
// Chained classes: multiple dots without space (e.g., ".btn.primary")
if ((selector.match(/\./g) || []).length > 1) {
return "hard";
}
// Type + class: element followed by dot (e.g., "a.btn", "div.card")
if (/^[a-z]+\.[a-z]/i.test(selector)) {
return "hard";
}
}
// Simple selector → Medium
return "medium";
}
// Feedback elements cache
let feedbackElement = null;
let feedbackTimeout = null;
@@ -138,6 +181,42 @@ export function renderLesson(titleEl, descriptionEl, taskEl, previewEl, prefixEl
// The LessonEngine will handle this when it's first set
}
/**
* Render the difficulty badge (right-aligned in title row)
* @param {HTMLElement} container - The container element (lesson-title-row)
* @param {Object} lesson - The lesson object
*/
export function renderDifficultyBadge(container, lesson) {
// Remove existing difficulty wrapper if any
const existingWrapper = container.querySelector(".difficulty-wrapper");
if (existingWrapper) {
existingWrapper.remove();
}
// Compute difficulty
const difficulty = computeLessonDifficulty(lesson);
// Create wrapper for right-alignment
const wrapper = document.createElement("span");
wrapper.className = "difficulty-wrapper";
// Create badge element with three bars
const badge = document.createElement("span");
badge.className = `difficulty-badge difficulty-${difficulty}`;
badge.setAttribute("aria-label", t(`difficulty_${difficulty}_label`));
badge.setAttribute("title", t(`difficulty_${difficulty}`));
// Add three bars
for (let i = 0; i < 3; i++) {
const bar = document.createElement("span");
bar.className = "bar";
badge.appendChild(bar);
}
wrapper.appendChild(badge);
container.appendChild(wrapper);
}
/**
* Update the level indicator
* @param {HTMLElement} element - The level indicator element

View File

@@ -112,6 +112,12 @@ const translations = {
// Dynamic content
loadingFallbackText: "Could not load lesson. Please select one from the menu or check the help.",
completed: "Completed",
difficulty_easy: "Easy",
difficulty_medium: "Medium",
difficulty_hard: "Hard",
difficulty_easy_label: "Easy difficulty - selector provided",
difficulty_medium_label: "Medium difficulty - simple selector required",
difficulty_hard_label: "Hard difficulty - compound selector required",
successMessage: "CRISPY! ٩(◕‿◕)۶ Your code works correctly.",
keepTrying: "Keep trying!",
failedToLoad: "Failed to load modules. Please refresh the page.",
@@ -336,7 +342,13 @@ const translations = {
// Dynamic content
loadingFallbackText: "Lektion konnte nicht geladen werden. Bitte wähle eine aus dem Menü oder prüfe die Hilfe.",
completed: "Erledigt",
completed: "Fertig",
difficulty_easy: "Einfach",
difficulty_medium: "Mittel",
difficulty_hard: "Schwer",
difficulty_easy_label: "Einfach - Selektor vorgegeben",
difficulty_medium_label: "Mittel - einfacher Selektor erforderlich",
difficulty_hard_label: "Schwer - zusammengesetzter Selektor erforderlich",
successMessage: "CRISPY! ٩(◕‿◕)۶ Dein Code funktioniert.",
keepTrying: "Weiter versuchen!",
failedToLoad: "Module konnten nicht geladen werden. Bitte Seite neu laden.",
@@ -345,7 +357,7 @@ const translations = {
untitledLesson: "Unbenannte Lektion",
// Landing page
landingHeroTitle: "Lerne Web Entwicklung",
landingHeroTitle: "Web Entwicklung lernen",
landingHeroHighlight: "mit CODE CRISPIES",
landingHeroSubtitle: "Meistere HTML, CSS und Tailwind durch praktische Übungen mit sofortigem Feedback. Kostenlos und Open Source.",
landingCtaStart: "Jetzt starten",
@@ -560,6 +572,12 @@ const translations = {
// Dynamic content
loadingFallbackText: "Nie można załadować lekcji. Wybierz jedną z menu lub sprawdź pomoc.",
completed: "Ukończono",
difficulty_easy: "Łatwe",
difficulty_medium: "Średnie",
difficulty_hard: "Trudne",
difficulty_easy_label: "Łatwe - selektor podany",
difficulty_medium_label: "Średnie - wymagany prosty selektor",
difficulty_hard_label: "Trudne - wymagany złożony selektor",
successMessage: "CRISPY! ٩(◕‿◕)۶ Twój kod działa poprawnie.",
keepTrying: "Próbuj dalej!",
failedToLoad: "Nie udało się załadować modułów. Odśwież stronę.",
@@ -784,6 +802,12 @@ const translations = {
// Dynamic content
loadingFallbackText: "No se pudo cargar la lección. Selecciona una del menú o consulta la ayuda.",
completed: "Completado",
difficulty_easy: "Fácil",
difficulty_medium: "Medio",
difficulty_hard: "Difícil",
difficulty_easy_label: "Fácil - selector proporcionado",
difficulty_medium_label: "Medio - selector simple requerido",
difficulty_hard_label: "Difícil - selector compuesto requerido",
successMessage: "¡CRISPY! ٩(◕‿◕)۶ Tu código funciona correctamente.",
keepTrying: "¡Sigue intentando!",
failedToLoad: "No se pudieron cargar los módulos. Actualiza la página.",
@@ -1007,6 +1031,12 @@ const translations = {
// Dynamic content
loadingFallbackText: "تعذر تحميل الدرس. اختر واحدًا من القائمة أو تحقق من المساعدة.",
completed: "مكتمل",
difficulty_easy: "سهل",
difficulty_medium: "متوسط",
difficulty_hard: "صعب",
difficulty_easy_label: "سهل - المحدد مُعطى",
difficulty_medium_label: "متوسط - يتطلب محدد بسيط",
difficulty_hard_label: "صعب - يتطلب محدد مركب",
successMessage: "CRISPY! ٩(◕‿◕)۶ الكود يعمل بشكل صحيح.",
keepTrying: "استمر في المحاولة!",
failedToLoad: "فشل تحميل الوحدات. قم بتحديث الصفحة.",
@@ -1228,6 +1258,12 @@ const translations = {
// Dynamic content
loadingFallbackText: "Не вдалося завантажити урок. Виберіть один з меню або перевірте допомогу.",
completed: "Завершено",
difficulty_easy: "Легко",
difficulty_medium: "Середнє",
difficulty_hard: "Складно",
difficulty_easy_label: "Легко - селектор наданий",
difficulty_medium_label: "Середнє - потрібен простий селектор",
difficulty_hard_label: "Складно - потрібен складений селектор",
successMessage: "CRISPY! ٩(◕‿◕)۶ Ваш код працює правильно.",
keepTrying: "Продовжуйте спроби!",
failedToLoad: "Не вдалося завантажити модулі. Оновіть сторінку.",

View File

@@ -463,6 +463,13 @@
<button id="close-sidebar" class="close-btn" data-i18n-aria-label="closeMenu" aria-label="Close menu">&times;</button>
</div>
<nav class="sidebar-section sidebar-nav-mobile" aria-label="Learning paths">
<a href="#css" class="sidebar-nav-link" data-section="css">CSS</a>
<a href="#html" class="sidebar-nav-link" data-section="html">HTML</a>
<a href="#tailwind" class="sidebar-nav-link" data-section="tailwind">Tailwind</a>
<button id="auth-trigger-mobile" class="sidebar-nav-link sidebar-auth-link" data-i18n="authLogin">Log In</button>
</nav>
<div class="sidebar-section">
<h4 data-i18n="progress">Progress</h4>
<div class="progress-display milestone-progress" id="progress-display">
@@ -504,23 +511,27 @@
<div class="sidebar-section">
<h4 data-i18n="settings">Settings</h4>
<label class="setting-row">
<span class="setting-label" data-i18n="language">Language</span>
<select id="lang-select" class="lang-select">
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="pl">Polski</option>
<option value="es">Español</option>
<option value="ar">العربية</option>
<option value="uk">Українська</option>
</select>
</label>
<label class="toggle-switch">
<input type="checkbox" id="disable-feedback-toggle" checked />
<span class="toggle-slider"></span>
<span class="toggle-label" data-i18n="showHints">Show Hints</span>
</label>
<button id="reset-btn" class="btn btn-text" data-i18n="resetAllProgress">Reset All Progress</button>
<div class="settings-card">
<label class="settings-row">
<span class="settings-label" data-i18n="language">Language</span>
<select id="lang-select" class="lang-select">
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="pl">Polski</option>
<option value="es">Español</option>
<option value="ar">العربية</option>
<option value="uk">Українська</option>
</select>
</label>
<label class="settings-row">
<span class="settings-label" data-i18n="showHints">Show Hints</span>
<input type="checkbox" id="disable-feedback-toggle" class="settings-toggle" checked />
</label>
<div class="settings-row">
<span class="settings-label" data-i18n="resetAllProgress">Reset All Progress</span>
<button id="reset-btn" class="btn btn-sm btn-ghost" data-i18n="reset">Reset</button>
</div>
</div>
</div>
<footer class="app-footer">

View File

@@ -308,6 +308,16 @@ kbd {
gap: var(--spacing-sm);
}
#auth-trigger-header {
display: none;
}
@media (min-width: 769px) {
#auth-trigger-header {
display: inline-flex;
}
}
/* ================= GAME LAYOUT ================= */
.game-layout {
display: flex;
@@ -374,6 +384,7 @@ kbd {
gap: 0.5rem;
background: var(--primary-bg-medium);
color: var(--primary-color);
min-width: 0;
padding: 4px 12px;
border-radius: 16px;
font-size: 0.8rem;
@@ -385,12 +396,18 @@ kbd {
.module-name {
text-transform: uppercase;
letter-spacing: 0.5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.module-pill .level-indicator {
color: var(--primary-dark);
font-weight: 500;
opacity: 0.8;
white-space: nowrap;
flex-shrink: 0;
}
.lesson-title-row {
@@ -398,7 +415,13 @@ kbd {
align-items: center;
gap: 0.5rem;
margin-bottom: var(--spacing-sm);
flex-wrap: wrap;
}
.lesson-title-row .difficulty-wrapper {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
#lesson-title {
@@ -447,6 +470,28 @@ kbd {
vertical-align: middle;
}
.difficulty-badge {
display: inline-flex;
align-items: flex-end;
gap: 2px;
vertical-align: middle;
}
.difficulty-badge .bar {
width: 3px;
border-radius: 1px;
background: var(--border-color);
}
.difficulty-badge .bar:nth-child(1) { height: 6px; }
.difficulty-badge .bar:nth-child(2) { height: 9px; }
.difficulty-badge .bar:nth-child(3) { height: 12px; }
.difficulty-easy .bar:nth-child(1),
.difficulty-medium .bar:nth-child(1),
.difficulty-medium .bar:nth-child(2),
.difficulty-hard .bar { background: var(--light-text); }
.lesson-description {
font-size: 0.95rem;
line-height: 1.6;
@@ -996,14 +1041,69 @@ kbd {
border-bottom: none;
}
/* Mobile navigation in sidebar */
.sidebar-nav-mobile {
display: flex;
flex-direction: column;
gap: 0;
}
.sidebar-nav-link {
display: block;
padding: 0.6rem var(--spacing-md);
font-size: 0.9rem;
font-weight: 500;
text-decoration: none;
color: var(--text-color);
border-bottom: 1px solid var(--border-color);
}
.sidebar-nav-link:last-child {
border-bottom: none;
}
.sidebar-nav-link:hover {
background: var(--primary-bg-light);
color: var(--primary-color);
}
.sidebar-auth-link {
width: calc(100% - 2 * var(--spacing-md));
margin: var(--spacing-sm) var(--spacing-md);
padding: 0.5rem 1rem;
text-align: center;
background: var(--primary-color);
color: var(--white-text);
border: none;
border-radius: var(--border-radius-md);
cursor: pointer;
font-family: inherit;
font-weight: 500;
}
.sidebar-auth-link:hover {
background: var(--primary-dark);
}
@media (min-width: 769px) {
.sidebar-nav-mobile {
display: none;
}
}
/* Make the lessons nav section fill available space */
nav.sidebar-section {
nav.sidebar-section:not(.sidebar-nav-mobile) {
flex: 1;
overflow-y: auto;
min-height: 0;
padding-bottom: var(--spacing-md);
}
.sidebar-nav-mobile {
flex: none;
padding: 0;
}
.sidebar-section h4 {
font-size: 0.75rem;
text-transform: uppercase;
@@ -1388,8 +1488,63 @@ button.lesson-list-item {
display: none;
}
/* ================= TOGGLE SWITCH ================= */
/* Setting row (for label + control) */
/* ================= SETTINGS CARD ================= */
.settings-card {
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-md);
overflow: hidden;
}
.settings-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.settings-row:last-child {
border-bottom: none;
}
.settings-label {
font-size: 0.9rem;
color: var(--text-color);
}
.settings-toggle {
width: 40px;
height: 22px;
appearance: none;
background: var(--border-color);
border-radius: 11px;
position: relative;
cursor: pointer;
transition: background 0.2s;
}
.settings-toggle::before {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
background: white;
border-radius: 50%;
transition: transform 0.2s;
}
.settings-toggle:checked {
background: var(--primary-color);
}
.settings-toggle:checked::before {
transform: translateX(18px);
}
/* Legacy setting row (for label + control) */
.setting-row {
display: flex;
align-items: center;
@@ -3177,11 +3332,16 @@ input:checked + .toggle-slider::before {
.module-pill {
flex: 1;
min-width: 0;
max-width: 100%;
margin: 0 var(--spacing-sm);
justify-content: center;
overflow: hidden;
}
.module-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;

View File

@@ -1,5 +1,5 @@
import { describe, test, expect, vi, beforeEach } from "vitest";
import { renderModuleList, renderLesson, renderLevelIndicator, showFeedback, clearFeedback } from "../../src/helpers/renderer.js";
import { renderModuleList, renderLesson, renderLevelIndicator, showFeedback, clearFeedback, computeLessonDifficulty } from "../../src/helpers/renderer.js";
describe("Renderer Module", () => {
beforeEach(() => {
@@ -176,4 +176,68 @@ describe("Renderer Module", () => {
clearFeedback();
});
});
describe("computeLessonDifficulty", () => {
test("should return 'easy' when codePrefix contains selector", () => {
expect(computeLessonDifficulty({
codePrefix: ".text {\n ",
solution: "color: coral;"
})).toBe("easy");
expect(computeLessonDifficulty({
codePrefix: "h1, h2, h3 {\n ",
solution: "color: steelblue;"
})).toBe("easy");
});
test("should return 'medium' for simple type selector", () => {
expect(computeLessonDifficulty({
codePrefix: "",
solution: "p {\n color: steelblue;\n}"
})).toBe("medium");
expect(computeLessonDifficulty({
codePrefix: "",
solution: "a {\n color: coral;\n}"
})).toBe("medium");
});
test("should return 'medium' for simple class selector", () => {
expect(computeLessonDifficulty({
codePrefix: "",
solution: ".badge {\n background: tomato;\n}"
})).toBe("medium");
});
test("should return 'hard' for descendant selectors", () => {
expect(computeLessonDifficulty({
codePrefix: "",
solution: ".nav a {\n color: white;\n}"
})).toBe("hard");
expect(computeLessonDifficulty({
codePrefix: "",
solution: ".card p {\n font-size: 0.9rem;\n}"
})).toBe("hard");
});
test("should return 'hard' for chained class selectors", () => {
expect(computeLessonDifficulty({
codePrefix: "",
solution: ".btn.primary {\n background: steelblue;\n}"
})).toBe("hard");
});
test("should return 'hard' for type+class selectors", () => {
expect(computeLessonDifficulty({
codePrefix: "",
solution: "a.btn {\n text-decoration: none;\n}"
})).toBe("hard");
});
test("should handle missing fields gracefully", () => {
expect(computeLessonDifficulty({})).toBe("medium");
expect(computeLessonDifficulty({ codePrefix: null })).toBe("medium");
});
});
});