23 Commits

Author SHA1 Message Date
26b9b99937 feat: add JavaScript lesson section with starter lessons and sidebar section grouping headers
Implementation following plan:
- S01: Update JSON schema to support 'javascript' mode
- S02: Install @codemirror/lang-javascript dependency
- S03: Define JavaScript section in sections.js
- S04: Create 3 JavaScript lesson JSON files (variables, DOM, events)
- S05: Add JavaScript validation support in validator.js
- S06: Add JavaScript preview rendering in LessonEngine.js
- S07: Add JavaScript CodeMirror mode and editor config
- S08: Register JavaScript modules in all language stores
- S09: Add JavaScript section to landing page, navigation, and app config
- S10: Add sidebar section grouping headers with category mapping
- S11: Update tests for JavaScript mode and section headers
2026-03-28 14:03:45 +01:00
7ab095718b chore(nix): add flake with claude-code in devShell 2026-01-25 21:28:16 +01:00
5a243f332a chore: temporarily disable Tailwind CSS section
- Comment out Tailwind nav links in header and sidebar
- Comment out Tailwind card on landing page
- Remove tailwind from router SECTIONS array
- Remove tailwind from landing page progress tracking

Tailwind content and styles remain in codebase for easy re-enabling.
2026-01-25 15:40:53 +01:00
739470e045 feat: add Markdown learning module with 8 beginner lessons
- Add markdown-basics module with lessons for headings, text formatting,
  lists, links, and inline code
- Integrate markdown section with blue color theme (#5b8dd9)
- Add markdown mode support in CodeEditor and LessonEngine
- Add markdown preview rendering using marked library
- Add section overview page with educational content
- Add markdown reference page with syntax guide
- Add i18n translations for 6 languages (EN, DE, PL, ES, AR, UK)
- Update router to recognize #markdown as section route
- Add all section-specific CSS styles for markdown theme
2026-01-25 11:27:07 +01:00
07aafa0d89 feat(app): pass codePrefix/codeSuffix to editor on lesson load
- Update loadCurrentLesson() to pass prefix/suffix to editor
- Use getEditableValue() in runCode() to get only user code
2026-01-25 02:00:07 +01:00
eb82eed826 style: add styling for read-only editor zones
Dimmed appearance with subtle background for codePrefix/codeSuffix regions.
2026-01-25 01:59:59 +01:00
82f6e46d3c feat(editor): add read-only zones support for codePrefix/codeSuffix
- Add initWithContext() method for prefix/suffix initialization
- Implement changeFilter to prevent edits in read-only zones
- Add transactionFilter to constrain cursor to editable area
- Add visual decorations with cm-readonly-zone class
- Update getValue/setValue to handle editable portions correctly
2026-01-25 00:39:09 +01:00
847b261f16 fix: restore gradient scaling and distribute milestone colors evenly 2026-01-16 23:45:19 +01:00
2ce88f9cb7 fix: milestone colors now correctly reflect position in 0-100 gradient 2026-01-16 23:40:38 +01:00
a8ef3d3c5c fix: progress bar now shows milestone progress instead of overall progress 2026-01-16 23:21:41 +01:00
0f5ac81fe8 fix: shorten German reset progress label to 'Fortschritt' 2026-01-16 22:01:15 +01:00
cf0d2cba51 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
2026-01-16 21:47:47 +01:00
d5bd23615f fix: update German CTA to 'Jetzt gleich anfangen' 2026-01-16 16:53:38 +01:00
fcc6748aae fix: update German landing hero text to 'Lerne Web Entwicklung mit CODE CRISPIES' 2026-01-16 16:41:17 +01:00
5c16a8a767 feat: redesign sidebar progress to show milestone progress and total lessons 2026-01-16 16:37:23 +01:00
17b3d5380d fix: show Next button disabled in playground instead of hiding it 2026-01-16 15:32:21 +01:00
f9311d83f7 fix: remove centered class toggle - grid layout handles positioning 2026-01-16 15:31:17 +01:00
f4ce61ba64 fix: add gap between game controls grid items 2026-01-16 15:29:44 +01:00
813d669302 fix: use CSS grid for game controls to keep pill centered when next is hidden 2026-01-16 15:28:45 +01:00
9328399dcb fix: change 'Crispy Code' to 'Code Crispy' in landing page title 2026-01-16 15:27:14 +01:00
857ae9c3ef fix: move device notice under section cards on landing page 2026-01-16 15:26:10 +01:00
c91e8d6f32 fix: make copyright year dynamic in footer 2026-01-16 15:25:04 +01:00
Michael Czechowski
9821e014c5 Merge pull request #2 from nextlevelshit/feature/new-lessons
Feature/new lessons
2026-01-16 15:20:31 +01:00
24 changed files with 2205 additions and 132 deletions

61
flake.lock generated Normal file
View File

@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1768564909,
"narHash": "sha256-Kell/SpJYVkHWMvnhqJz/8DqQg2b6PguxVWOuadbHCc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View File

@@ -9,13 +9,14 @@
outputs = { self, nixpkgs, flake-utils }: outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system: flake-utils.lib.eachDefaultSystem (system:
let let
pkgs = nixpkgs.legacyPackages.${system}; pkgs = import nixpkgs { inherit system; config.allowUnfree = true; };
in { in {
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [ buildInputs = with pkgs; [
nodejs_20 nodejs_20
nodePackages.npm nodePackages.npm
gnumake gnumake
claude-code
]; ];
shellHook = '' shellHook = ''

View File

@@ -0,0 +1,197 @@
{
"$schema": "../schemas/code-crispies-module-schema.json",
"id": "markdown-basics",
"title": "Markdown Basics",
"description": "Learn to format text documents with Markdown, a simple and readable markup language used everywhere from GitHub to note-taking apps.",
"mode": "markdown",
"difficulty": "beginner",
"lessons": [
{
"id": "md-headings",
"title": "Headings",
"description": "Markdown uses hash symbols <kbd>#</kbd> to create headings. One <kbd>#</kbd> creates the largest heading (h1), two <kbd>##</kbd> creates a smaller heading (h2), and so on up to six levels.<br><br><pre># Main Title\n## Section\n### Subsection</pre>",
"task": "Create a main heading by typing <kbd># Hello</kbd>",
"previewHTML": "",
"previewBaseCSS": "",
"sandboxCSS": "",
"initialCode": "",
"solution": "# Hello",
"previewContainer": "preview-area",
"validations": [
{
"type": "regex",
"value": "^#\\s+.+",
"message": "Start with <kbd>#</kbd> followed by a space and your heading text"
},
{
"type": "contains",
"value": "Hello",
"message": "Your heading should contain <kbd>Hello</kbd>"
}
]
},
{
"id": "md-heading-levels",
"title": "Heading Levels",
"description": "Use more <kbd>#</kbd> symbols for smaller headings. <kbd>##</kbd> creates an h2, <kbd>###</kbd> an h3. This creates a clear document structure with visual hierarchy.",
"task": "Create an h2 heading with <kbd>## About</kbd> followed by an h3 heading with <kbd>### Details</kbd>",
"previewHTML": "",
"previewBaseCSS": "",
"sandboxCSS": "",
"initialCode": "",
"solution": "## About\n\n### Details",
"previewContainer": "preview-area",
"validations": [
{
"type": "regex",
"value": "^##\\s+About",
"message": "Start with <kbd>## About</kbd>"
},
{
"type": "regex",
"value": "###\\s+Details",
"message": "Add <kbd>### Details</kbd> for the h3 heading"
}
]
},
{
"id": "md-bold",
"title": "Bold Text",
"description": "Wrap text in double asterisks <kbd>**</kbd> or double underscores <kbd>__</kbd> to make it <strong>bold</strong>. This emphasizes important words or phrases.",
"task": "Make the word <kbd>important</kbd> bold by wrapping it with <kbd>**</kbd>",
"previewHTML": "",
"previewBaseCSS": "",
"sandboxCSS": "",
"initialCode": "This is important text.",
"solution": "This is **important** text.",
"previewContainer": "preview-area",
"validations": [
{
"type": "regex",
"value": "\\*\\*important\\*\\*",
"message": "Wrap <kbd>important</kbd> with double asterisks: <kbd>**important**</kbd>"
}
]
},
{
"id": "md-italic",
"title": "Italic Text",
"description": "Wrap text in single asterisks <kbd>*</kbd> or single underscores <kbd>_</kbd> to make it <em>italic</em>. Use this for subtle emphasis or titles of works.",
"task": "Make the word <kbd>elegant</kbd> italic by wrapping it with <kbd>*</kbd>",
"previewHTML": "",
"previewBaseCSS": "",
"sandboxCSS": "",
"initialCode": "A simple and elegant solution.",
"solution": "A simple and *elegant* solution.",
"previewContainer": "preview-area",
"validations": [
{
"type": "regex",
"value": "\\*elegant\\*",
"message": "Wrap <kbd>elegant</kbd> with single asterisks: <kbd>*elegant*</kbd>"
},
{
"type": "not_contains",
"value": "**elegant**",
"message": "Use single asterisks for italic, not double"
}
]
},
{
"id": "md-unordered-list",
"title": "Bullet Lists",
"description": "Create bullet lists using <kbd>-</kbd>, <kbd>*</kbd>, or <kbd>+</kbd> at the start of each line. Each item goes on its own line.",
"task": "Create a bullet list with three items: <kbd>Apple</kbd>, <kbd>Banana</kbd>, <kbd>Cherry</kbd>",
"previewHTML": "",
"previewBaseCSS": "",
"sandboxCSS": "",
"initialCode": "",
"solution": "- Apple\n- Banana\n- Cherry",
"previewContainer": "preview-area",
"validations": [
{
"type": "regex",
"value": "^[-*+]\\s+Apple",
"message": "Start with a dash, asterisk, or plus followed by <kbd>Apple</kbd>"
},
{
"type": "regex",
"value": "[-*+]\\s+Banana",
"message": "Add <kbd>Banana</kbd> as a list item"
},
{
"type": "regex",
"value": "[-*+]\\s+Cherry",
"message": "Add <kbd>Cherry</kbd> as a list item"
}
]
},
{
"id": "md-ordered-list",
"title": "Numbered Lists",
"description": "Create numbered lists by starting lines with <kbd>1.</kbd>, <kbd>2.</kbd>, etc. Markdown automatically numbers them in sequence.",
"task": "Create a numbered list: <kbd>Wake up</kbd>, <kbd>Eat breakfast</kbd>, <kbd>Start coding</kbd>",
"previewHTML": "",
"previewBaseCSS": "",
"sandboxCSS": "",
"initialCode": "",
"solution": "1. Wake up\n2. Eat breakfast\n3. Start coding",
"previewContainer": "preview-area",
"validations": [
{
"type": "regex",
"value": "\\d+\\.\\s+Wake up",
"message": "Start with a number and period: <kbd>1. Wake up</kbd>"
},
{
"type": "regex",
"value": "\\d+\\.\\s+Eat breakfast",
"message": "Add <kbd>Eat breakfast</kbd> as a numbered item"
},
{
"type": "regex",
"value": "\\d+\\.\\s+Start coding",
"message": "Add <kbd>Start coding</kbd> as a numbered item"
}
]
},
{
"id": "md-links",
"title": "Links",
"description": "Create links with <kbd>[text](url)</kbd>. The text in brackets is what readers see; the URL in parentheses is where they go when clicked.",
"task": "Create a link that shows <kbd>Google</kbd> and goes to <kbd>https://google.com</kbd>",
"previewHTML": "",
"previewBaseCSS": "",
"sandboxCSS": "",
"initialCode": "",
"solution": "[Google](https://google.com)",
"previewContainer": "preview-area",
"validations": [
{
"type": "regex",
"value": "\\[Google\\]\\(https?://google\\.com\\)",
"message": "Use the format <kbd>[Google](https://google.com)</kbd>"
}
]
},
{
"id": "md-inline-code",
"title": "Inline Code",
"description": "Wrap text in backticks <kbd>`</kbd> to format it as code. This is useful for variable names, commands, or short code snippets in your text.",
"task": "Format <kbd>npm install</kbd> as inline code using backticks",
"previewHTML": "",
"previewBaseCSS": "",
"sandboxCSS": "",
"initialCode": "Run npm install to install dependencies.",
"solution": "Run `npm install` to install dependencies.",
"previewContainer": "preview-area",
"validations": [
{
"type": "regex",
"value": "`npm install`",
"message": "Wrap <kbd>npm install</kbd> with backticks: <kbd>`npm install`</kbd>"
}
]
}
]
}

View File

@@ -0,0 +1,98 @@
{
"$schema": "../schemas/code-crispies-module-schema.json",
"id": "js-variables",
"title": "JS Variables",
"description": "Learn to declare variables with let and const, work with strings and numbers, and use template literals to build dynamic text.",
"mode": "javascript",
"difficulty": "beginner",
"lessons": [
{
"id": "js-const",
"title": "Constants",
"description": "Use <kbd>const</kbd> to declare a variable that cannot be reassigned. Constants are great for values that stay the same throughout your program.",
"task": "Declare a constant named <kbd>name</kbd> with the value <kbd>\"Ada\"</kbd>",
"previewHTML": "<p id=\"out\">Waiting...</p>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }",
"sandboxCSS": "",
"initialCode": "",
"solution": "const name = \"Ada\";",
"codePrefix": "",
"codeSuffix": "\ndocument.getElementById(\"out\").textContent = name;",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "const",
"message": "Use <kbd>const</kbd> to declare the variable"
},
{
"type": "regex",
"value": "const\\s+name\\s*=",
"message": "Name your constant <kbd>name</kbd>"
},
{
"type": "regex",
"value": "[\"']Ada[\"']",
"message": "Set the value to <kbd>\"Ada\"</kbd>"
}
]
},
{
"id": "js-let",
"title": "Let Variables",
"description": "Use <kbd>let</kbd> to declare a variable that can be reassigned later. This is useful when you need to update a value.",
"task": "Declare a variable <kbd>score</kbd> with <kbd>let</kbd> and set it to <kbd>0</kbd>",
"previewHTML": "<p id=\"out\">Waiting...</p>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }",
"sandboxCSS": "",
"initialCode": "",
"solution": "let score = 0;",
"codePrefix": "",
"codeSuffix": "\ndocument.getElementById(\"out\").textContent = \"Score: \" + score;",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "let",
"message": "Use <kbd>let</kbd> to declare the variable"
},
{
"type": "regex",
"value": "let\\s+score\\s*=\\s*0",
"message": "Set <kbd>score</kbd> to <kbd>0</kbd>"
}
]
},
{
"id": "js-template",
"title": "Template Literals",
"description": "Template literals use backticks <kbd>`</kbd> and <kbd>${}</kbd> to embed expressions inside strings. They make building dynamic text much easier than string concatenation.",
"task": "Create a <kbd>const msg</kbd> using a template literal: <kbd>`Hi, ${name}!`</kbd>",
"previewHTML": "<p id=\"out\">Waiting...</p>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }",
"sandboxCSS": "",
"initialCode": "",
"solution": "const msg = `Hi, ${name}!`;",
"codePrefix": "const name = \"Ada\";\n",
"codeSuffix": "\ndocument.getElementById(\"out\").textContent = msg;",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "${",
"message": "Use <kbd>${}</kbd> to embed the variable inside the template"
},
{
"type": "regex",
"value": "`[^`]*\\$\\{\\s*name\\s*\\}[^`]*`",
"message": "Embed <kbd>name</kbd> inside a template literal with backticks"
},
{
"type": "regex",
"value": "const\\s+msg\\s*=",
"message": "Assign the result to a constant named <kbd>msg</kbd>"
}
]
}
]
}

93
lessons/51-js-dom.json Normal file
View File

@@ -0,0 +1,93 @@
{
"$schema": "../schemas/code-crispies-module-schema.json",
"id": "js-dom",
"title": "JS DOM",
"description": "Learn to select HTML elements with querySelector, change their text content, and modify their styles using JavaScript.",
"mode": "javascript",
"difficulty": "beginner",
"lessons": [
{
"id": "js-query",
"title": "Select an Element",
"description": "Use <kbd>document.querySelector()</kbd> to find an element by its CSS selector. It returns the first matching element.",
"task": "Select the element with id <kbd>box</kbd> and store it in a <kbd>const el</kbd>",
"previewHTML": "<div id=\"box\" style=\"width:80px;height:80px;background:coral;border-radius:8px;\"></div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }",
"sandboxCSS": "",
"initialCode": "",
"solution": "const el = document.querySelector(\"#box\");",
"codePrefix": "",
"codeSuffix": "\nif (el) el.textContent = \"Found!\";",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "querySelector",
"message": "Use <kbd>document.querySelector()</kbd> to select the element"
},
{
"type": "regex",
"value": "querySelector\\s*\\([\"']#box[\"']\\)",
"message": "Pass <kbd>\"#box\"</kbd> as the selector"
},
{
"type": "regex",
"value": "const\\s+el\\s*=",
"message": "Store the result in a constant named <kbd>el</kbd>"
}
]
},
{
"id": "js-text",
"title": "Change Text",
"description": "The <kbd>textContent</kbd> property lets you read or change the text inside an element. Setting it replaces all the element's text.",
"task": "Set the <kbd>textContent</kbd> of <kbd>el</kbd> to <kbd>\"Hello!\"</kbd>",
"previewHTML": "<p id=\"msg\">Old text</p>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }",
"sandboxCSS": "",
"initialCode": "",
"solution": "el.textContent = \"Hello!\";",
"codePrefix": "const el = document.querySelector(\"#msg\");\n",
"codeSuffix": "",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "textContent",
"message": "Use the <kbd>textContent</kbd> property"
},
{
"type": "regex",
"value": "el\\.textContent\\s*=\\s*[\"']Hello![\"']",
"message": "Set <kbd>el.textContent</kbd> to <kbd>\"Hello!\"</kbd>"
}
]
},
{
"id": "js-style",
"title": "Change Style",
"description": "Access an element's inline styles through the <kbd>style</kbd> property. CSS properties use camelCase in JavaScript, so <kbd>background-color</kbd> becomes <kbd>backgroundColor</kbd>.",
"task": "Set <kbd>el.style.backgroundColor</kbd> to <kbd>\"gold\"</kbd>",
"previewHTML": "<div id=\"box\" style=\"width:80px;height:80px;background:coral;border-radius:8px;\"></div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }",
"sandboxCSS": "",
"initialCode": "",
"solution": "el.style.backgroundColor = \"gold\";",
"codePrefix": "const el = document.querySelector(\"#box\");\n",
"codeSuffix": "",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "style.backgroundColor",
"message": "Use <kbd>el.style.backgroundColor</kbd>"
},
{
"type": "regex",
"value": "\\.style\\.backgroundColor\\s*=\\s*[\"']gold[\"']",
"message": "Set the background color to <kbd>\"gold\"</kbd>"
}
]
}
]
}

103
lessons/52-js-events.json Normal file
View File

@@ -0,0 +1,103 @@
{
"$schema": "../schemas/code-crispies-module-schema.json",
"id": "js-events",
"title": "JS Events",
"description": "Learn to respond to user actions by adding event listeners for clicks, toggling classes, and updating the page dynamically.",
"mode": "javascript",
"difficulty": "beginner",
"lessons": [
{
"id": "js-click",
"title": "Click Handler",
"description": "Use <kbd>addEventListener(\"click\", ...)</kbd> to run code when an element is clicked. The first argument is the event type and the second is a callback function.",
"task": "Add a <kbd>click</kbd> event listener to <kbd>btn</kbd> that sets <kbd>out.textContent</kbd> to <kbd>\"Clicked!\"</kbd>",
"previewHTML": "<button id=\"btn\" style=\"padding:8px 16px;font-size:1rem;\">Click me</button>\n<p id=\"out\">Waiting...</p>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }",
"sandboxCSS": "",
"initialCode": "",
"solution": "btn.addEventListener(\"click\", () => {\n out.textContent = \"Clicked!\";\n});",
"codePrefix": "const btn = document.querySelector(\"#btn\");\nconst out = document.querySelector(\"#out\");\n",
"codeSuffix": "",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "addEventListener",
"message": "Use <kbd>addEventListener</kbd> to listen for events"
},
{
"type": "regex",
"value": "addEventListener\\s*\\(\\s*[\"']click[\"']",
"message": "Listen for the <kbd>\"click\"</kbd> event"
},
{
"type": "regex",
"value": "textContent\\s*=\\s*[\"']Clicked![\"']",
"message": "Set <kbd>out.textContent</kbd> to <kbd>\"Clicked!\"</kbd> inside the handler"
}
]
},
{
"id": "js-toggle",
"title": "Toggle a Class",
"description": "Use <kbd>classList.toggle()</kbd> to add a class if it's missing or remove it if it's present. This is perfect for on/off states like toggling dark mode or active states.",
"task": "Inside the click handler, call <kbd>box.classList.toggle(\"on\")</kbd>",
"previewHTML": "<div id=\"box\" style=\"width:80px;height:80px;background:coral;border-radius:8px;transition:background 0.3s;\"></div>\n<style>.on { background: mediumseagreen !important; }</style>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }",
"sandboxCSS": "",
"initialCode": "",
"solution": "box.addEventListener(\"click\", () => {\n box.classList.toggle(\"on\");\n});",
"codePrefix": "const box = document.querySelector(\"#box\");\n",
"codeSuffix": "",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "classList.toggle",
"message": "Use <kbd>classList.toggle()</kbd> to toggle the class"
},
{
"type": "regex",
"value": "classList\\.toggle\\s*\\(\\s*[\"']on[\"']\\s*\\)",
"message": "Toggle the class <kbd>\"on\"</kbd>"
},
{
"type": "contains",
"value": "addEventListener",
"message": "Use <kbd>addEventListener</kbd> to listen for clicks"
}
]
},
{
"id": "js-counter",
"title": "Simple Counter",
"description": "Combine variables and event listeners to build interactive features. Use <kbd>let</kbd> for a value that changes, and update the display each time the button is clicked.",
"task": "In the click handler, increment <kbd>count</kbd> by 1 and set <kbd>out.textContent</kbd> to <kbd>count</kbd>",
"previewHTML": "<button id=\"btn\" style=\"padding:8px 16px;font-size:1rem;\">Add 1</button>\n<p id=\"out\">0</p>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }",
"sandboxCSS": "",
"initialCode": "",
"solution": "btn.addEventListener(\"click\", () => {\n count++;\n out.textContent = count;\n});",
"codePrefix": "const btn = document.querySelector(\"#btn\");\nconst out = document.querySelector(\"#out\");\nlet count = 0;\n",
"codeSuffix": "",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "addEventListener",
"message": "Use <kbd>addEventListener</kbd> to listen for clicks"
},
{
"type": "regex",
"value": "count\\s*\\+\\+|count\\s*\\+=\\s*1|count\\s*=\\s*count\\s*\\+\\s*1",
"message": "Increment <kbd>count</kbd> by 1 (use <kbd>count++</kbd>)"
},
{
"type": "regex",
"value": "out\\.textContent\\s*=\\s*count",
"message": "Update the display with <kbd>out.textContent = count</kbd>"
}
]
}
]
}

65
package-lock.json generated
View File

@@ -13,12 +13,15 @@
"@codemirror/commands": "^6.10.1", "@codemirror/commands": "^6.10.1",
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11", "@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/state": "^6.5.2", "@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.4", "@codemirror/view": "^6.39.4",
"@emmetio/codemirror6-plugin": "^0.4.0", "@emmetio/codemirror6-plugin": "^0.4.0",
"@supabase/supabase-js": "^2.90.1", "@supabase/supabase-js": "^2.90.1",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"marked": "^17.0.1",
"whatwg-fetch": "^3.6.20" "whatwg-fetch": "^3.6.20"
}, },
"devDependencies": { "devDependencies": {
@@ -156,7 +159,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz",
"integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==", "integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@codemirror/language": "^6.0.0", "@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0", "@codemirror/state": "^6.0.0",
@@ -169,7 +171,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.1.tgz", "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.1.tgz",
"integrity": "sha512-uWDWFypNdQmz2y1LaNJzK7fL7TYKLeUAU0npEC685OKTF3KcQ2Vu3klIM78D7I6wGhktme0lh3CuQLv0ZCrD9Q==", "integrity": "sha512-uWDWFypNdQmz2y1LaNJzK7fL7TYKLeUAU0npEC685OKTF3KcQ2Vu3klIM78D7I6wGhktme0lh3CuQLv0ZCrD9Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@codemirror/language": "^6.0.0", "@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.4.0", "@codemirror/state": "^6.4.0",
@@ -182,7 +183,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.0.0", "@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0", "@codemirror/language": "^6.0.0",
@@ -196,7 +196,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.0.0", "@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0", "@codemirror/lang-css": "^6.0.0",
@@ -210,9 +209,9 @@
} }
}, },
"node_modules/@codemirror/lang-javascript": { "node_modules/@codemirror/lang-javascript": {
"version": "6.2.4", "version": "6.2.5",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz", "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
"integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==", "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.0.0", "@codemirror/autocomplete": "^6.0.0",
@@ -224,12 +223,26 @@
"@lezer/javascript": "^1.0.0" "@lezer/javascript": "^1.0.0"
} }
}, },
"node_modules/@codemirror/lang-markdown": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz",
"integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.3.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/language": { "node_modules/@codemirror/language": {
"version": "6.11.3", "version": "6.11.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz", "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz",
"integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==", "integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@codemirror/state": "^6.0.0", "@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0", "@codemirror/view": "^6.23.0",
@@ -266,7 +279,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@marijn/find-cluster-break": "^1.0.0" "@marijn/find-cluster-break": "^1.0.0"
} }
@@ -288,7 +300,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.4.tgz", "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.4.tgz",
"integrity": "sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA==", "integrity": "sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@codemirror/state": "^6.5.0", "@codemirror/state": "^6.5.0",
"crelt": "^1.0.6", "crelt": "^1.0.6",
@@ -384,7 +395,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@@ -408,7 +418,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -974,9 +983,9 @@
} }
}, },
"node_modules/@lezer/common": { "node_modules/@lezer/common": {
"version": "1.4.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.4.0.tgz", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.0.tgz",
"integrity": "sha512-DVeMRoGrgn/k45oQNu189BoW4SZwgZFzJ1+1TV5j2NJ/KFC83oa/enRqZSGshyeMk5cPWMhsKs9nx+8o0unwGg==", "integrity": "sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@lezer/css": { "node_modules/@lezer/css": {
@@ -1030,6 +1039,16 @@
"@lezer/common": "^1.0.0" "@lezer/common": "^1.0.0"
} }
}, },
"node_modules/@lezer/markdown": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.3.tgz",
"integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": { "node_modules/@marijn/find-cluster-break": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
@@ -2336,7 +2355,6 @@
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cssstyle": "^4.2.1", "cssstyle": "^4.2.1",
"data-urls": "^5.0.0", "data-urls": "^5.0.0",
@@ -2433,6 +2451,18 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/marked": {
"version": "17.0.1",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz",
"integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/min-indent": { "node_modules/min-indent": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@@ -2579,7 +2609,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -3123,7 +3152,6 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",
@@ -3222,7 +3250,6 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/chai": "^5.2.2", "@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4", "@vitest/expect": "3.2.4",

View File

@@ -37,12 +37,15 @@
"@codemirror/commands": "^6.10.1", "@codemirror/commands": "^6.10.1",
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11", "@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/state": "^6.5.2", "@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.4", "@codemirror/view": "^6.39.4",
"@emmetio/codemirror6-plugin": "^0.4.0", "@emmetio/codemirror6-plugin": "^0.4.0",
"@supabase/supabase-js": "^2.90.1", "@supabase/supabase-js": "^2.90.1",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"marked": "^17.0.1",
"whatwg-fetch": "^3.6.20" "whatwg-fetch": "^3.6.20"
} }
} }

View File

@@ -19,8 +19,8 @@
}, },
"mode": { "mode": {
"type": "string", "type": "string",
"enum": ["css", "tailwind", "html"], "enum": ["css", "tailwind", "html", "markdown", "javascript"],
"description": "Whether this module teaches CSS, Tailwind, or HTML" "description": "Whether this module teaches CSS, Tailwind, HTML, Markdown, or JavaScript"
}, },
"difficulty": { "difficulty": {
"type": "string", "type": "string",
@@ -60,7 +60,7 @@
}, },
"mode": { "mode": {
"type": "string", "type": "string",
"enum": ["css", "tailwind", "html"], "enum": ["css", "tailwind", "html", "markdown", "javascript"],
"description": "Override module mode for individual lessons" "description": "Override module mode for individual lessons"
}, },
"tailwindConfig": { "tailwindConfig": {

View File

@@ -1,6 +1,6 @@
import { LessonEngine } from "./impl/LessonEngine.js"; import { LessonEngine } from "./impl/LessonEngine.js";
import { CodeEditor, crispyEditorTheme } from "./impl/CodeEditor.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 { loadModules } from "./config/lessons.js";
import { initI18n, t, getLanguage, setLanguage, applyTranslations } from "./i18n.js"; import { initI18n, t, getLanguage, setLanguage, applyTranslations } from "./i18n.js";
import { parseHash, updateHash, replaceHash, getShareableUrl, RouteType, navigateTo } from "./helpers/router.js"; import { parseHash, updateHash, replaceHash, getShareableUrl, RouteType, navigateTo } from "./helpers/router.js";
@@ -164,7 +164,8 @@ const elements = {
refFooterLessonLinks: document.getElementById("ref-footer-lesson-links"), refFooterLessonLinks: document.getElementById("ref-footer-lesson-links"),
sectionFooterLessonLinks: document.getElementById("section-footer-lesson-links"), sectionFooterLessonLinks: document.getElementById("section-footer-lesson-links"),
progressFill: document.getElementById("progress-fill"), progressFill: document.getElementById("progress-fill"),
progressText: document.getElementById("progress-text"), progressCurrent: document.getElementById("progress-current"),
progressTotal: document.getElementById("progress-total"),
milestonesContainer: document.getElementById("milestones"), milestonesContainer: document.getElementById("milestones"),
resetBtn: document.getElementById("reset-btn"), resetBtn: document.getElementById("reset-btn"),
disableFeedbackToggle: document.getElementById("disable-feedback-toggle"), disableFeedbackToggle: document.getElementById("disable-feedback-toggle"),
@@ -317,14 +318,17 @@ let lastMilestoneReached = 0;
function updateProgressDisplay() { function updateProgressDisplay() {
const stats = lessonEngine.getProgressStats(); const stats = lessonEngine.getProgressStats();
// Update progress bar - shows overall progress with full gradient // Update progress bar - shows progress towards next milestone
const progressPercent = stats.percentComplete || 1; // CSS variable scales gradient so only first X% of colors show
const progressPercent = stats.progressToNext || 1;
elements.progressFill.style.width = `${progressPercent}%`; elements.progressFill.style.width = `${progressPercent}%`;
elements.progressFill.style.setProperty('--progress-percent', progressPercent); elements.progressFill.style.setProperty('--progress-percent', progressPercent);
// Update progress text - show completed of total lessons // Update progress current - show progress towards next milestone
elements.progressText.textContent = t("progressTextMilestone", { elements.progressCurrent.textContent = `${stats.totalCompleted}/${stats.nextMilestone}`;
completed: stats.totalCompleted,
// Update progress total - show total lessons
elements.progressTotal.textContent = t("progressTotal", {
total: stats.totalLessons total: stats.totalLessons
}); });
@@ -569,6 +573,16 @@ function updateEditorForMode(mode) {
label: "CSS Editor", label: "CSS Editor",
cmMode: "css" cmMode: "css"
}, },
markdown: {
placeholder: "# Heading\n\nWrite your **Markdown** here...",
label: "Markdown Editor",
cmMode: "markdown"
},
javascript: {
placeholder: "// Write your JavaScript here...",
label: "JavaScript Editor",
cmMode: "javascript"
},
playground: { playground: {
placeholder: "<style>\n /* CSS here */\n</style>\n\n<!-- HTML here -->", placeholder: "<style>\n /* CSS here */\n</style>\n\n<!-- HTML here -->",
label: "HTML & CSS", label: "HTML & CSS",
@@ -645,21 +659,28 @@ function loadCurrentLesson() {
lesson lesson
); );
// Render difficulty badge
renderDifficultyBadge(elements.lessonTitleRow, lesson);
// Set user code in CodeMirror (clear history to prevent undo/redo across lessons) // Set user code in CodeMirror (clear history to prevent undo/redo across lessons)
// Pass codePrefix/codeSuffix as read-only zones for CSS mode
if (codeEditor) { if (codeEditor) {
codeEditor.setValueAndClearHistory(engineState.userCode); const prefix = lesson.codePrefix || "";
const suffix = lesson.codeSuffix || "";
codeEditor.setValueAndClearHistory(engineState.userCode, prefix, suffix);
} }
// Update Run button text based on completion status // Update Run button text based on completion status
if (engineState.isCompleted) { if (engineState.isCompleted) {
elements.runBtn.querySelector("span").textContent = t("rerun"); elements.runBtn.querySelector("span").textContent = t("rerun");
// Add completion badge if not present // Add completion badge to difficulty-wrapper if not present
if (!document.querySelector(".completion-badge")) { const wrapper = document.querySelector(".difficulty-wrapper");
if (wrapper && !wrapper.querySelector(".completion-badge")) {
const badge = document.createElement("span"); const badge = document.createElement("span");
badge.className = "completion-badge"; badge.className = "completion-badge";
badge.textContent = t("completed"); badge.textContent = t("completed");
elements.lessonTitleRow.appendChild(badge); wrapper.appendChild(badge);
} }
// Show gradient border and glow for completed lessons // Show gradient border and glow for completed lessons
@@ -668,7 +689,7 @@ function loadCurrentLesson() {
} else { } else {
elements.runBtn.querySelector("span").textContent = t("run"); 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"); const badge = document.querySelector(".completion-badge");
if (badge) badge.remove(); if (badge) badge.remove();
elements.previewWrapper?.classList.remove("completed-glow"); elements.previewWrapper?.classList.remove("completed-glow");
@@ -755,15 +776,11 @@ function updateNavigationButtons() {
const engineState = lessonEngine.getCurrentState(); const engineState = lessonEngine.getCurrentState();
const isPlayground = engineState.lesson?.mode === "playground"; const isPlayground = engineState.lesson?.mode === "playground";
// Hide next button in playground mode
elements.nextBtn.classList.toggle("hidden", isPlayground);
elements.gameControls?.classList.toggle("centered", isPlayground);
// Update button states // Update button states
elements.prevBtn.disabled = !engineState.canGoPrev; elements.prevBtn.disabled = !engineState.canGoPrev;
elements.nextBtn.disabled = !engineState.canGoNext; elements.nextBtn.disabled = isPlayground || !engineState.canGoNext;
elements.prevBtn.classList.toggle("btn-disabled", !engineState.canGoPrev); elements.prevBtn.classList.toggle("btn-disabled", !engineState.canGoPrev);
elements.nextBtn.classList.toggle("btn-disabled", !engineState.canGoNext); elements.nextBtn.classList.toggle("btn-disabled", isPlayground || !engineState.canGoNext);
} }
function nextLesson() { function nextLesson() {
@@ -865,7 +882,7 @@ function loadRandomTemplate() {
} }
function runCode() { function runCode() {
const userCode = codeEditor ? codeEditor.getValue() : ""; const userCode = codeEditor ? codeEditor.getEditableValue() : "";
const engineState = lessonEngine.getCurrentState(); const engineState = lessonEngine.getCurrentState();
const isPlayground = engineState.lesson?.mode === "playground"; const isPlayground = engineState.lesson?.mode === "playground";
@@ -1402,6 +1419,143 @@ const sectionContent = {
</div> </div>
</div> </div>
</div> </div>
`,
markdown: `
<div class="section-overview">
<p><strong>Markdown</strong> is a lightweight markup language created by John Gruber in 2004. It lets you write formatted text using plain text syntax that's easy to read and write. Markdown is used everywhere—from GitHub READMEs to documentation, note-taking apps, and content management systems.</p>
<p>The beauty of Markdown is its simplicity: <code># Heading</code> creates a heading, <code>**bold**</code> makes text bold, and <code>[link](url)</code> creates a link. No complex HTML tags needed. Markdown files can be converted to HTML, PDF, or many other formats.</p>
</div>
<div class="topic-row">
<div class="topic-text">
<h2>Headings & Structure</h2>
<p>Create document structure with headings using <code>#</code> symbols. One <code>#</code> for h1, two <code>##</code> for h2, up to six levels. This creates a clear hierarchy in your documents.</p>
<p>
<a href="#markdown-basics/0" class="topic-link">Practice headings →</a>
</p>
</div>
<div class="topic-code">
<div class="code-block">
<pre><code># Main Title
## Section
### Subsection
#### Detail</code></pre>
</div>
</div>
</div>
<div class="topic-row">
<div class="topic-text">
<h2>Text Formatting</h2>
<p>Emphasize text with <code>**bold**</code> or <code>*italic*</code>. Combine them with <code>***bold italic***</code>. Use backticks for <code>\`inline code\`</code> to highlight commands or code snippets in your text.</p>
<p>
<a href="#markdown-basics/2" class="topic-link">Practice formatting →</a>
</p>
</div>
<div class="topic-code">
<div class="code-block">
<pre><code>This is **bold** text.
This is *italic* text.
This is \`inline code\`.</code></pre>
</div>
</div>
</div>
<div class="topic-row">
<div class="topic-text">
<h2>Lists</h2>
<p>Create bullet lists with <code>-</code>, <code>*</code>, or <code>+</code>. Numbered lists use <code>1.</code>, <code>2.</code>, etc. Indent items with spaces to create nested lists for complex outlines.</p>
<p>
<a href="#markdown-basics/4" class="topic-link">Practice lists →</a>
</p>
</div>
<div class="topic-code">
<div class="code-block">
<pre><code>- First item
- Second item
- Nested item
1. Step one
2. Step two
3. Step three</code></pre>
</div>
</div>
</div>
<div class="topic-row">
<div class="topic-text">
<h2>Links & Images</h2>
<p>Create links with <code>[text](url)</code> syntax. Images use the same format with an exclamation mark: <code>![alt text](image-url)</code>. The alt text describes the image for accessibility.</p>
<p>
<a href="#markdown-basics/6" class="topic-link">Practice links →</a>
</p>
</div>
<div class="topic-code">
<div class="code-block">
<pre><code>[Visit Google](https://google.com)
![Logo](https://example.com/logo.png)</code></pre>
</div>
</div>
</div>
`,
javascript: `
<div class="section-overview">
<p><strong>JavaScript</strong> is the programming language of the web. It makes pages interactive—responding to clicks, updating content, and manipulating the DOM (Document Object Model). Every modern website uses JavaScript to create dynamic user experiences.</p>
<p>Start with the fundamentals: declaring variables with <code>const</code> and <code>let</code>, selecting elements with <code>querySelector</code>, changing content and styles, and responding to user events like clicks.</p>
</div>
<div class="topic-row">
<div class="topic-text">
<h2>Variables</h2>
<p>Store values using <code>const</code> (cannot be reassigned) and <code>let</code> (can be updated). Use template literals with backticks to build dynamic strings with embedded expressions.</p>
<p>
<a href="#js-variables/0" class="topic-link">Practice variables</a>
</p>
</div>
<div class="topic-code">
<div class="code-block">
<pre><code>const name = "Ada";
let score = 0;
const msg = \`Hi, \${name}!\`;</code></pre>
</div>
</div>
</div>
<div class="topic-row">
<div class="topic-text">
<h2>DOM Manipulation</h2>
<p>Use <code>document.querySelector()</code> to find elements, <code>textContent</code> to change text, and <code>style</code> to modify CSS properties directly from JavaScript.</p>
<p>
<a href="#js-dom/0" class="topic-link">Practice DOM</a>
</p>
</div>
<div class="topic-code">
<div class="code-block">
<pre><code>const el = document.querySelector("#box");
el.textContent = "Hello!";
el.style.backgroundColor = "gold";</code></pre>
</div>
</div>
</div>
<div class="topic-row">
<div class="topic-text">
<h2>Events</h2>
<p>Use <code>addEventListener</code> to respond to user interactions. Handle clicks, toggle classes, and build interactive features like counters and toggles.</p>
<p>
<a href="#js-events/0" class="topic-link">Practice events</a>
</p>
</div>
<div class="topic-code">
<div class="code-block">
<pre><code>btn.addEventListener("click", () => {
count++;
out.textContent = count;
});</code></pre>
</div>
</div>
</div>
` `
}; };
@@ -1918,6 +2072,105 @@ const referenceContent = {
</section> </section>
<p class="ref-see-also">Learn: <a href="#html">HTML Section</a> | Style with: <a href="#reference/css">CSS Properties</a></p> <p class="ref-see-also">Learn: <a href="#html">HTML Section</a> | Style with: <a href="#reference/css">CSS Properties</a></p>
`,
markdown: `
<h1>Markdown Syntax Reference</h1>
<p class="ref-intro">A quick guide to Markdown syntax for formatting text documents. Markdown is used in GitHub, documentation, and note-taking apps.</p>
<section class="ref-section">
<h2>Text Formatting</h2>
<table class="ref-table">
<thead><tr><th>Syntax</th><th>Result</th><th>Notes</th></tr></thead>
<tbody>
<tr><td><code>**bold**</code></td><td><strong>bold</strong></td><td>Or use __bold__</td></tr>
<tr><td><code>*italic*</code></td><td><em>italic</em></td><td>Or use _italic_</td></tr>
<tr><td><code>***bold italic***</code></td><td><strong><em>bold italic</em></strong></td><td>Combine both</td></tr>
<tr><td><code>~~strikethrough~~</code></td><td><s>strikethrough</s></td><td>GFM extension</td></tr>
<tr><td><code>\`inline code\`</code></td><td><code>inline code</code></td><td>Monospace font</td></tr>
</tbody>
</table>
</section>
<section class="ref-section">
<h2>Headings</h2>
<table class="ref-table">
<thead><tr><th>Syntax</th><th>Level</th><th>Usage</th></tr></thead>
<tbody>
<tr><td><code># Heading 1</code></td><td>h1</td><td>Document title</td></tr>
<tr><td><code>## Heading 2</code></td><td>h2</td><td>Main sections</td></tr>
<tr><td><code>### Heading 3</code></td><td>h3</td><td>Subsections</td></tr>
<tr><td><code>#### Heading 4</code></td><td>h4</td><td>Minor sections</td></tr>
<tr><td><code>##### Heading 5</code></td><td>h5</td><td>Rarely used</td></tr>
<tr><td><code>###### Heading 6</code></td><td>h6</td><td>Smallest heading</td></tr>
</tbody>
</table>
</section>
<section class="ref-section">
<h2>Lists</h2>
<table class="ref-table">
<thead><tr><th>Syntax</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
<tr><td><code>- Item</code></td><td>Unordered</td><td>Or use * or +</td></tr>
<tr><td><code>1. Item</code></td><td>Ordered</td><td>Numbers auto-increment</td></tr>
<tr><td><code> - Nested</code></td><td>Nested list</td><td>2-space indent</td></tr>
<tr><td><code>- [x] Task</code></td><td>Task list</td><td>GFM extension</td></tr>
<tr><td><code>- [ ] Task</code></td><td>Unchecked task</td><td>Interactive checkboxes</td></tr>
</tbody>
</table>
</section>
<section class="ref-section">
<h2>Links & Images</h2>
<table class="ref-table">
<thead><tr><th>Syntax</th><th>Purpose</th><th>Example</th></tr></thead>
<tbody>
<tr><td><code>[text](url)</code></td><td>Inline link</td><td>[Google](https://google.com)</td></tr>
<tr><td><code>[text](url "title")</code></td><td>Link with tooltip</td><td>Hover text</td></tr>
<tr><td><code>![alt](url)</code></td><td>Image</td><td>Alt text for accessibility</td></tr>
<tr><td><code>&lt;url&gt;</code></td><td>Auto-link</td><td>URLs become clickable</td></tr>
<tr><td><code>[ref]: url</code></td><td>Reference link</td><td>Define at doc bottom</td></tr>
</tbody>
</table>
</section>
<section class="ref-section">
<h2>Code Blocks</h2>
<table class="ref-table">
<thead><tr><th>Syntax</th><th>Purpose</th><th>Notes</th></tr></thead>
<tbody>
<tr><td><code>\`\`\`</code></td><td>Fenced code</td><td>3 backticks or tildes</td></tr>
<tr><td><code>\`\`\`js</code></td><td>Syntax highlight</td><td>Add language identifier</td></tr>
<tr><td><code> code</code></td><td>Indented code</td><td>4-space indent</td></tr>
</tbody>
</table>
</section>
<section class="ref-section">
<h2>Block Elements</h2>
<table class="ref-table">
<thead><tr><th>Syntax</th><th>Element</th><th>Notes</th></tr></thead>
<tbody>
<tr><td><code>> Quote</code></td><td>Blockquote</td><td>Nest with >></td></tr>
<tr><td><code>---</code></td><td>Horizontal rule</td><td>Or *** or ___</td></tr>
<tr><td><code>| A | B |</code></td><td>Table</td><td>GFM extension</td></tr>
</tbody>
</table>
</section>
<section class="ref-section">
<h2>Tables (GFM)</h2>
<div class="ref-example">
<pre><code>| Header 1 | Header 2 |
|----------|----------|
| Cell 1 | Cell 2 |
| Cell 3 | Cell 4 |</code></pre>
</div>
<p>Use colons for alignment: <code>:---</code> (left), <code>:---:</code> (center), <code>---:</code> (right)</p>
</section>
<p class="ref-see-also">Learn: <a href="#markdown">Markdown Section</a> | Also try: <a href="#html">HTML Elements</a></p>
` `
}; };
@@ -1963,7 +2216,7 @@ function updatePageMeta(route) {
break; break;
case RouteType.SECTION: { case RouteType.SECTION: {
const sectionNames = { css: "CSS", html: "HTML", tailwind: "Tailwind CSS" }; const sectionNames = { css: "CSS", html: "HTML", tailwind: "Tailwind CSS", markdown: "Markdown" };
const sectionName = sectionNames[route.sectionId] || route.sectionId; const sectionName = sectionNames[route.sectionId] || route.sectionId;
title = `${sectionName} Lessons - CODE CRISPIES | Learn ${sectionName}`; title = `${sectionName} Lessons - CODE CRISPIES | Learn ${sectionName}`;
description = `Learn ${sectionName} through interactive coding exercises. Hands-on practice with instant feedback.`; description = `Learn ${sectionName} through interactive coding exercises. Hands-on practice with instant feedback.`;
@@ -1987,7 +2240,8 @@ function updatePageMeta(route) {
selectors: "CSS Selectors", selectors: "CSS Selectors",
flexbox: "Flexbox", flexbox: "Flexbox",
grid: "CSS Grid", grid: "CSS Grid",
html: "HTML Elements" html: "HTML Elements",
markdown: "Markdown Syntax"
}; };
const refName = refNames[route.refId] || "Reference"; const refName = refNames[route.refId] || "Reference";
title = `${refName} Reference - CODE CRISPIES`; title = `${refName} Reference - CODE CRISPIES`;
@@ -2119,7 +2373,7 @@ function showLandingPage() {
*/ */
function renderFooterLessonLinks() { function renderFooterLessonLinks() {
const modules = lessonEngine.modules || []; const modules = lessonEngine.modules || [];
const sectionGroups = { css: [], html: [] }; const sectionGroups = { css: [], html: [], markdown: [], javascript: [] };
modules.forEach((module) => { modules.forEach((module) => {
if (module.excludeFromProgress) return; if (module.excludeFromProgress) return;
@@ -2156,7 +2410,7 @@ function renderFooterLessonLinks() {
* Update progress indicators on landing page * Update progress indicators on landing page
*/ */
function updateLandingProgress() { function updateLandingProgress() {
["css", "html", "tailwind"].forEach((sectionId) => { ["css", "html", "markdown", "javascript"].forEach((sectionId) => { // tailwind temporarily disabled
const progressEl = document.getElementById(`${sectionId}-progress`); const progressEl = document.getElementById(`${sectionId}-progress`);
if (progressEl) { if (progressEl) {
const sectionModules = getModulesBySection(lessonEngine.modules, sectionId); const sectionModules = getModulesBySection(lessonEngine.modules, sectionId);
@@ -2242,7 +2496,7 @@ function showReferencePage(refId) {
const activeRef = refId || "css"; const activeRef = refId || "css";
// Map reference to section for color coding // Map reference to section for color coding
const refToSection = { css: "css", selectors: "css", flexbox: "css", grid: "css", html: "html" }; const refToSection = { css: "css", selectors: "css", flexbox: "css", grid: "css", html: "html", markdown: "markdown" };
updateSectionColor(refToSection[activeRef] || "css"); updateSectionColor(refToSection[activeRef] || "css");
// Track reference page view // Track reference page view
@@ -2448,6 +2702,11 @@ function init() {
// Initialize i18n before anything else // Initialize i18n before anything else
initI18n(); initI18n();
// Set dynamic year in footer
document.querySelectorAll(".current-year").forEach((el) => {
el.textContent = new Date().getFullYear();
});
loadUserSettings(); loadUserSettings();
// Restore cached lesson content immediately to avoid "Loading..." flash // Restore cached lesson content immediately to avoid "Loading..." flash
@@ -2476,6 +2735,11 @@ function init() {
elements.closeSidebar.addEventListener("click", closeSidebar); elements.closeSidebar.addEventListener("click", closeSidebar);
elements.sidebarBackdrop.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 // Logo click - navigate to home landing
elements.logoLink.addEventListener("click", (e) => { elements.logoLink.addEventListener("click", (e) => {
e.preventDefault(); e.preventDefault();

View File

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

View File

@@ -30,6 +30,10 @@ import gradientsEN from "../../lessons/09-gradients.json";
import filtersEN from "../../lessons/11-filters.json"; import filtersEN from "../../lessons/11-filters.json";
import positioningEN from "../../lessons/12-positioning.json"; import positioningEN from "../../lessons/12-positioning.json";
import pseudoElementsEN from "../../lessons/13-pseudo-elements.json"; import pseudoElementsEN from "../../lessons/13-pseudo-elements.json";
import markdownBasicsEN from "../../lessons/40-markdown-basics.json";
import jsVariablesEN from "../../lessons/50-js-variables.json";
import jsDomEN from "../../lessons/51-js-dom.json";
import jsEventsEN from "../../lessons/52-js-events.json";
import playgroundEN from "../../lessons/98-playground.json"; import playgroundEN from "../../lessons/98-playground.json";
import goodbyeEN from "../../lessons/99-goodbye.json"; import goodbyeEN from "../../lessons/99-goodbye.json";
@@ -162,6 +166,12 @@ const moduleStoreEN = [
htmlFieldsetEN, htmlFieldsetEN,
htmlDatalistEN, htmlDatalistEN,
htmlTablesEN, htmlTablesEN,
// Markdown
markdownBasicsEN,
// JavaScript
jsVariablesEN,
jsDomEN,
jsEventsEN,
// Outro // Outro
goodbyeEN, goodbyeEN,
playgroundEN playgroundEN
@@ -201,6 +211,12 @@ const moduleStoreDE = [
htmlFieldsetDE, htmlFieldsetDE,
htmlDatalistDE, htmlDatalistDE,
htmlTablesDE, htmlTablesDE,
// Markdown
markdownBasicsEN, // Using EN fallback until translated
// JavaScript
jsVariablesEN, // Using EN fallback until translated
jsDomEN, // Using EN fallback until translated
jsEventsEN, // Using EN fallback until translated
// Outro // Outro
goodbyeEN, goodbyeEN,
playgroundEN playgroundEN
@@ -240,6 +256,12 @@ const moduleStorePL = [
htmlFieldsetPL, htmlFieldsetPL,
htmlDatalistPL, htmlDatalistPL,
htmlTablesPL, htmlTablesPL,
// Markdown
markdownBasicsEN, // Using EN fallback until translated
// JavaScript
jsVariablesEN, // Using EN fallback until translated
jsDomEN, // Using EN fallback until translated
jsEventsEN, // Using EN fallback until translated
// Outro // Outro
goodbyeEN, goodbyeEN,
playgroundEN playgroundEN
@@ -279,6 +301,12 @@ const moduleStoreES = [
htmlFieldsetES, htmlFieldsetES,
htmlDatalistES, htmlDatalistES,
htmlTablesES, htmlTablesES,
// Markdown
markdownBasicsEN, // Using EN fallback until translated
// JavaScript
jsVariablesEN, // Using EN fallback until translated
jsDomEN, // Using EN fallback until translated
jsEventsEN, // Using EN fallback until translated
// Outro // Outro
goodbyeEN, goodbyeEN,
playgroundEN playgroundEN
@@ -318,6 +346,12 @@ const moduleStoreAR = [
htmlFieldsetAR, htmlFieldsetAR,
htmlDatalistAR, htmlDatalistAR,
htmlTablesAR, htmlTablesAR,
// Markdown
markdownBasicsEN, // Using EN fallback until translated
// JavaScript
jsVariablesEN, // Using EN fallback until translated
jsDomEN, // Using EN fallback until translated
jsEventsEN, // Using EN fallback until translated
// Outro // Outro
goodbyeEN, goodbyeEN,
playgroundEN playgroundEN
@@ -357,6 +391,12 @@ const moduleStoreUK = [
htmlFieldsetUK, htmlFieldsetUK,
htmlDatalistUK, htmlDatalistUK,
htmlTablesUK, htmlTablesUK,
// Markdown
markdownBasicsEN, // Using EN fallback until translated
// JavaScript
jsVariablesEN, // Using EN fallback until translated
jsDomEN, // Using EN fallback until translated
jsEventsEN, // Using EN fallback until translated
// Outro // Outro
goodbyeEN, goodbyeEN,
playgroundEN playgroundEN
@@ -372,6 +412,58 @@ const moduleStores = {
uk: moduleStoreUK uk: moduleStoreUK
}; };
/**
* Category labels for sidebar section headers.
* Maps module IDs to their visual grouping label.
*/
const moduleCategories = {
// CSS Basics
"css-basic-selectors": "CSS Basics",
colors: "CSS Basics",
gradients: "CSS Basics",
typography: "CSS Basics",
"box-model": "CSS Basics",
// CSS Layout
flexbox: "CSS Layout",
grid: "CSS Layout",
positioning: "CSS Layout",
"units-variables": "CSS Layout",
responsive: "CSS Layout",
// CSS Polish
"transitions-animations": "CSS Polish",
filters: "CSS Polish",
"pseudo-elements": "CSS Polish",
// HTML Structure
"html-elements": "HTML Structure",
"html-semantic": "HTML Structure",
"html-figure": "HTML Structure",
"html-svg": "HTML Structure",
// HTML Interactive
"html-details-summary": "HTML Interactive",
"html-dialog": "HTML Interactive",
"html-progress-meter": "HTML Interactive",
"html-forms-basic": "HTML Interactive",
"html-forms-validation": "HTML Interactive",
"html-forms-fieldset": "HTML Interactive",
"html-datalist": "HTML Interactive",
"html-tables": "HTML Interactive",
// Markdown
"markdown-basics": "Markdown",
// JavaScript
"js-variables": "JavaScript",
"js-dom": "JavaScript",
"js-events": "JavaScript"
};
/**
* Get the sidebar category label for a module
* @param {string} moduleId - The module ID
* @returns {string|null} The category label, or null for uncategorized modules (welcome, outro)
*/
export function getModuleCategory(moduleId) {
return moduleCategories[moduleId] || null;
}
/** /**
* Load all available modules for a given language * Load all available modules for a given language
* @param {string} language - Language code ('en', 'de', 'pl', 'es', 'ar', 'uk') * @param {string} language - Language code ('en', 'de', 'pl', 'es', 'ar', 'uk')

View File

@@ -24,6 +24,20 @@ export const sections = {
description: "Utility-first CSS framework", description: "Utility-first CSS framework",
color: "#26a69a", color: "#26a69a",
order: 3 order: 3
},
markdown: {
id: "markdown",
title: "Markdown",
description: "Lightweight markup language for formatting text",
color: "#5b8dd9",
order: 4
},
javascript: {
id: "javascript",
title: "JavaScript",
description: "Variables, DOM manipulation, and event handling",
color: "#f0c040",
order: 5
} }
}; };
@@ -57,6 +71,8 @@ export function getModuleSection(module) {
const mode = module.mode || "css"; const mode = module.mode || "css";
if (mode === "html") return "html"; if (mode === "html") return "html";
if (mode === "tailwind") return "tailwind"; if (mode === "tailwind") return "tailwind";
if (mode === "markdown") return "markdown";
if (mode === "javascript") return "javascript";
return "css"; return "css";
} }

View File

@@ -2,6 +2,50 @@
* Renderer - Handles UI updates for the CSS learning platform * Renderer - Handles UI updates for the CSS learning platform
*/ */
import { t } from "../i18n.js"; import { t } from "../i18n.js";
import { getModuleCategory } from "../config/lessons.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 // Feedback elements cache
let feedbackElement = null; let feedbackElement = null;
@@ -29,8 +73,21 @@ export function renderModuleList(container, modules, onSelectModule, onSelectLes
} }
} }
// Track current category for section headers
let currentCategory = null;
// Create list items for each module // Create list items for each module
modules.forEach((module) => { modules.forEach((module) => {
// Insert section header when category changes
const category = getModuleCategory(module.id);
if (category && category !== currentCategory) {
currentCategory = category;
const header = document.createElement("h3");
header.className = "module-section-header";
header.textContent = category;
header.setAttribute("aria-hidden", "true");
container.appendChild(header);
}
// Create module container // Create module container
// Use native <details>/<summary> for expand/collapse // Use native <details>/<summary> for expand/collapse
const moduleContainer = document.createElement("details"); const moduleContainer = document.createElement("details");
@@ -138,6 +195,42 @@ export function renderLesson(titleEl, descriptionEl, taskEl, previewEl, prefixEl
// The LessonEngine will handle this when it's first set // 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 * Update the level indicator
* @param {HTMLElement} element - The level indicator element * @param {HTMLElement} element - The level indicator element

View File

@@ -8,6 +8,7 @@
* - #css -> CSS section landing * - #css -> CSS section landing
* - #html -> HTML section landing * - #html -> HTML section landing
* - #tailwind -> Tailwind section landing * - #tailwind -> Tailwind section landing
* - #markdown -> Markdown section landing
* - #reference/css -> CSS cheatsheet * - #reference/css -> CSS cheatsheet
* - #module/index -> Lesson (e.g., #flexbox/2) * - #module/index -> Lesson (e.g., #flexbox/2)
*/ */
@@ -26,7 +27,7 @@ export const RouteType = {
/** /**
* Valid section IDs * Valid section IDs
*/ */
const SECTIONS = ["css", "html", "tailwind"]; const SECTIONS = ["css", "html", "markdown"]; // tailwind temporarily disabled
/** /**
* Valid language codes for URL-based switching * Valid language codes for URL-based switching

View File

@@ -10,6 +10,8 @@ export function validateUserCode(userCode, lesson) {
return validateHtmlCode(userCode, lesson); return validateHtmlCode(userCode, lesson);
case "tailwind": case "tailwind":
return validateTailwindClasses(userCode, lesson); return validateTailwindClasses(userCode, lesson);
case "javascript":
return validateJavaScriptCode(userCode, lesson);
case "css": case "css":
default: default:
return validateCssCode(userCode, lesson); return validateCssCode(userCode, lesson);
@@ -204,6 +206,80 @@ function validateHtmlCode(userHtml, lesson) {
return result; return result;
} }
/**
* Validate user JavaScript code against the lesson requirements
* @param {string} userCode - User submitted JavaScript code
* @param {Object} lesson - The current lesson object
* @returns {Object} Validation result with isValid and message properties
*/
function validateJavaScriptCode(userCode, lesson) {
if (!lesson || !lesson.validations) {
return { isValid: true, message: "No validations specified for this lesson." };
}
const validations = lesson.validations;
let result = {
isValid: true,
validCases: 0,
totalCases: validations.length,
message: "Your CODE looks CRISPY!"
};
for (const validation of validations) {
const { type, value, message, options } = validation;
let validationPassed = false;
switch (type) {
case "contains":
validationPassed = containsValidation(userCode, value, options);
if (!validationPassed) {
result = {
...result,
isValid: false,
message: message || `Your code should include "${value}".`
};
}
break;
case "not_contains":
validationPassed = !containsValidation(userCode, value, options);
if (!validationPassed) {
result = {
...result,
isValid: false,
message: message || `Your code should not include "${value}".`
};
}
break;
case "regex":
validationPassed = regexValidation(userCode, value, options);
if (!validationPassed) {
result = {
...result,
isValid: false,
message: message || "Your code does not match the expected pattern."
};
}
break;
default:
console.warn(`Unknown JavaScript validation type: ${type}`);
validationPassed = true;
}
if (validationPassed) {
result.validCases++;
} else {
return result;
}
}
result.validCases = validations.length;
return result;
}
function validateTailwindClasses(userClasses, lesson) { function validateTailwindClasses(userClasses, lesson) {
if (!lesson || !lesson.validations) { if (!lesson || !lesson.validations) {
return { isValid: true, message: "No validations specified for this lesson." }; return { isValid: true, message: "No validations specified for this lesson." };

View File

@@ -41,6 +41,7 @@ const translations = {
progress: "Progress", progress: "Progress",
progressText: "{percent}% Complete ({completed}/{total})", progressText: "{percent}% Complete ({completed}/{total})",
progressTextMilestone: "{completed} of {total} lessons completed", progressTextMilestone: "{completed} of {total} lessons completed",
progressTotal: "{total} lessons total",
lessons: "Lessons", lessons: "Lessons",
settings: "Settings", settings: "Settings",
showHints: "Show Hints", showHints: "Show Hints",
@@ -111,6 +112,12 @@ const translations = {
// Dynamic content // Dynamic content
loadingFallbackText: "Could not load lesson. Please select one from the menu or check the help.", loadingFallbackText: "Could not load lesson. Please select one from the menu or check the help.",
completed: "Completed", 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.", successMessage: "CRISPY! ٩(◕‿◕)۶ Your code works correctly.",
keepTrying: "Keep trying!", keepTrying: "Keep trying!",
failedToLoad: "Failed to load modules. Please refresh the page.", failedToLoad: "Failed to load modules. Please refresh the page.",
@@ -120,7 +127,7 @@ const translations = {
// Landing page // Landing page
landingHeroTitle: "Learn Web Development", landingHeroTitle: "Learn Web Development",
landingHeroHighlight: "Crispy Code", landingHeroHighlight: "Code Crispy",
landingHeroSubtitle: "Master HTML, CSS, and Tailwind through hands-on exercises with instant feedback. Free and open source.", landingHeroSubtitle: "Master HTML, CSS, and Tailwind through hands-on exercises with instant feedback. Free and open source.",
landingCtaStart: "Start Learning NOW", landingCtaStart: "Start Learning NOW",
landingWhyTitle: "Why CODE CRISPIES Works", landingWhyTitle: "Why CODE CRISPIES Works",
@@ -136,6 +143,7 @@ const translations = {
landingCssDesc: "Styling, layout, and animations", landingCssDesc: "Styling, layout, and animations",
landingHtmlDesc: "Semantic markup and native elements", landingHtmlDesc: "Semantic markup and native elements",
landingTailwindDesc: "Utility-first CSS framework", landingTailwindDesc: "Utility-first CSS framework",
landingMarkdownDesc: "Format text with simple syntax",
comingSoon: "Coming Soon", comingSoon: "Coming Soon",
landingCtaTitle: "Start Learning Today", landingCtaTitle: "Start Learning Today",
landingCtaSub: "Free and open source. No account required. Progress saved locally.", landingCtaSub: "Free and open source. No account required. Progress saved locally.",
@@ -264,10 +272,11 @@ const translations = {
progress: "Fortschritt", progress: "Fortschritt",
progressText: "{percent}% abgeschlossen ({completed}/{total})", progressText: "{percent}% abgeschlossen ({completed}/{total})",
progressTextMilestone: "{completed} von {total} Lektionen abgeschlossen", progressTextMilestone: "{completed} von {total} Lektionen abgeschlossen",
progressTotal: "{total} Lektionen insgesamt",
lessons: "Lektionen", lessons: "Lektionen",
settings: "Einstellungen", settings: "Einstellungen",
showHints: "Hinweise anzeigen", showHints: "Hinweise anzeigen",
resetAllProgress: "Fortschritt zurücksetzen", resetAllProgress: "Fortschritt",
openSource: "Open Source:", openSource: "Open Source:",
by: "von", by: "von",
@@ -334,7 +343,13 @@ const translations = {
// Dynamic content // Dynamic content
loadingFallbackText: "Lektion konnte nicht geladen werden. Bitte wähle eine aus dem Menü oder prüfe die Hilfe.", 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.", successMessage: "CRISPY! ٩(◕‿◕)۶ Dein Code funktioniert.",
keepTrying: "Weiter versuchen!", keepTrying: "Weiter versuchen!",
failedToLoad: "Module konnten nicht geladen werden. Bitte Seite neu laden.", failedToLoad: "Module konnten nicht geladen werden. Bitte Seite neu laden.",
@@ -343,8 +358,8 @@ const translations = {
untitledLesson: "Unbenannte Lektion", untitledLesson: "Unbenannte Lektion",
// Landing page // Landing page
landingHeroTitle: "Web Programmierung", landingHeroTitle: "Web Entwicklung lernen",
landingHeroHighlight: "Crispy Code", landingHeroHighlight: "mit CODE CRISPIES",
landingHeroSubtitle: "Meistere HTML, CSS und Tailwind durch praktische Übungen mit sofortigem Feedback. Kostenlos und Open Source.", landingHeroSubtitle: "Meistere HTML, CSS und Tailwind durch praktische Übungen mit sofortigem Feedback. Kostenlos und Open Source.",
landingCtaStart: "Jetzt starten", landingCtaStart: "Jetzt starten",
landingWhyTitle: "Warum CODE CRISPIES funktioniert", landingWhyTitle: "Warum CODE CRISPIES funktioniert",
@@ -362,8 +377,9 @@ const translations = {
landingCssDesc: "Styling, Layout und Animationen", landingCssDesc: "Styling, Layout und Animationen",
landingHtmlDesc: "Semantisches Markup und native Elemente", landingHtmlDesc: "Semantisches Markup und native Elemente",
landingTailwindDesc: "Utility-first CSS-Framework", landingTailwindDesc: "Utility-first CSS-Framework",
landingMarkdownDesc: "Text mit einfacher Syntax formatieren",
comingSoon: "Bald verfügbar", comingSoon: "Bald verfügbar",
landingCtaTitle: "Heute noch anfangen", landingCtaTitle: "Jetzt gleich anfangen",
landingCtaSub: "Kostenlos und Open Source. Kein Konto erforderlich. Fortschritt wird lokal gespeichert.", landingCtaSub: "Kostenlos und Open Source. Kein Konto erforderlich. Fortschritt wird lokal gespeichert.",
landingCtaButton: "Let's get crispy!", landingCtaButton: "Let's get crispy!",
@@ -487,6 +503,7 @@ const translations = {
progress: "Postęp", progress: "Postęp",
progressText: "{percent}% ukończone ({completed}/{total})", progressText: "{percent}% ukończone ({completed}/{total})",
progressTextMilestone: "{completed} z {total} lekcji ukończonych", progressTextMilestone: "{completed} z {total} lekcji ukończonych",
progressTotal: "{total} lekcji łącznie",
lessons: "Lekcje", lessons: "Lekcje",
settings: "Ustawienia", settings: "Ustawienia",
showHints: "Pokaż podpowiedzi", showHints: "Pokaż podpowiedzi",
@@ -557,6 +574,12 @@ const translations = {
// Dynamic content // Dynamic content
loadingFallbackText: "Nie można załadować lekcji. Wybierz jedną z menu lub sprawdź pomoc.", loadingFallbackText: "Nie można załadować lekcji. Wybierz jedną z menu lub sprawdź pomoc.",
completed: "Ukończono", 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.", successMessage: "CRISPY! ٩(◕‿◕)۶ Twój kod działa poprawnie.",
keepTrying: "Próbuj dalej!", keepTrying: "Próbuj dalej!",
failedToLoad: "Nie udało się załadować modułów. Odśwież stronę.", failedToLoad: "Nie udało się załadować modułów. Odśwież stronę.",
@@ -566,7 +589,7 @@ const translations = {
// Landing page // Landing page
landingHeroTitle: "Naucz się tworzenia stron", landingHeroTitle: "Naucz się tworzenia stron",
landingHeroHighlight: "Crispy Code", landingHeroHighlight: "Code Crispy",
landingHeroSubtitle: "Opanuj HTML, CSS i Tailwind poprzez praktyczne ćwiczenia z natychmiastową informacją zwrotną. Darmowe i open source.", landingHeroSubtitle: "Opanuj HTML, CSS i Tailwind poprzez praktyczne ćwiczenia z natychmiastową informacją zwrotną. Darmowe i open source.",
landingCtaStart: "Zacznij TERAZ", landingCtaStart: "Zacznij TERAZ",
landingWhyTitle: "Dlaczego CODE CRISPIES działa", landingWhyTitle: "Dlaczego CODE CRISPIES działa",
@@ -584,6 +607,7 @@ const translations = {
landingCssDesc: "Stylowanie, układy i animacje", landingCssDesc: "Stylowanie, układy i animacje",
landingHtmlDesc: "Semantyczne znaczniki i natywne elementy", landingHtmlDesc: "Semantyczne znaczniki i natywne elementy",
landingTailwindDesc: "Framework CSS oparty na klasach utility", landingTailwindDesc: "Framework CSS oparty na klasach utility",
landingMarkdownDesc: "Formatuj tekst prostą składnią",
comingSoon: "Wkrótce", comingSoon: "Wkrótce",
landingCtaTitle: "Zacznij naukę już dziś", landingCtaTitle: "Zacznij naukę już dziś",
landingCtaSub: "Darmowe i open source. Bez konta. Postęp zapisywany lokalnie.", landingCtaSub: "Darmowe i open source. Bez konta. Postęp zapisywany lokalnie.",
@@ -709,6 +733,7 @@ const translations = {
progress: "Progreso", progress: "Progreso",
progressText: "{percent}% completado ({completed}/{total})", progressText: "{percent}% completado ({completed}/{total})",
progressTextMilestone: "{completed} de {total} lecciones completadas", progressTextMilestone: "{completed} de {total} lecciones completadas",
progressTotal: "{total} lecciones en total",
lessons: "Lecciones", lessons: "Lecciones",
settings: "Configuración", settings: "Configuración",
showHints: "Mostrar pistas", showHints: "Mostrar pistas",
@@ -780,6 +805,12 @@ const translations = {
// Dynamic content // Dynamic content
loadingFallbackText: "No se pudo cargar la lección. Selecciona una del menú o consulta la ayuda.", loadingFallbackText: "No se pudo cargar la lección. Selecciona una del menú o consulta la ayuda.",
completed: "Completado", 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.", successMessage: "¡CRISPY! ٩(◕‿◕)۶ Tu código funciona correctamente.",
keepTrying: "¡Sigue intentando!", keepTrying: "¡Sigue intentando!",
failedToLoad: "No se pudieron cargar los módulos. Actualiza la página.", failedToLoad: "No se pudieron cargar los módulos. Actualiza la página.",
@@ -789,7 +820,7 @@ const translations = {
// Landing page // Landing page
landingHeroTitle: "Aprende desarrollo web", landingHeroTitle: "Aprende desarrollo web",
landingHeroHighlight: "Crispy Code", landingHeroHighlight: "Code Crispy",
landingHeroSubtitle: landingHeroSubtitle:
"Domina HTML, CSS y Tailwind a través de ejercicios prácticos con retroalimentación instantánea. Gratis y de código abierto.", "Domina HTML, CSS y Tailwind a través de ejercicios prácticos con retroalimentación instantánea. Gratis y de código abierto.",
landingCtaStart: "Empieza AHORA", landingCtaStart: "Empieza AHORA",
@@ -808,6 +839,7 @@ const translations = {
landingCssDesc: "Estilos, diseño y animaciones", landingCssDesc: "Estilos, diseño y animaciones",
landingHtmlDesc: "Marcado semántico y elementos nativos", landingHtmlDesc: "Marcado semántico y elementos nativos",
landingTailwindDesc: "Framework CSS basado en utilidades", landingTailwindDesc: "Framework CSS basado en utilidades",
landingMarkdownDesc: "Formatea texto con sintaxis simple",
comingSoon: "Próximamente", comingSoon: "Próximamente",
landingCtaTitle: "Empieza a aprender hoy", landingCtaTitle: "Empieza a aprender hoy",
landingCtaSub: "Gratis y de código abierto. Sin cuenta requerida. Progreso guardado localmente.", landingCtaSub: "Gratis y de código abierto. Sin cuenta requerida. Progreso guardado localmente.",
@@ -933,6 +965,7 @@ const translations = {
progress: "التقدم", progress: "التقدم",
progressText: "{percent}% مكتمل ({completed}/{total})", progressText: "{percent}% مكتمل ({completed}/{total})",
progressTextMilestone: "{completed} من {total} درس مكتمل", progressTextMilestone: "{completed} من {total} درس مكتمل",
progressTotal: "{total} درس إجمالي",
lessons: "الدروس", lessons: "الدروس",
settings: "الإعدادات", settings: "الإعدادات",
showHints: "إظهار التلميحات", showHints: "إظهار التلميحات",
@@ -1002,6 +1035,12 @@ const translations = {
// Dynamic content // Dynamic content
loadingFallbackText: "تعذر تحميل الدرس. اختر واحدًا من القائمة أو تحقق من المساعدة.", loadingFallbackText: "تعذر تحميل الدرس. اختر واحدًا من القائمة أو تحقق من المساعدة.",
completed: "مكتمل", completed: "مكتمل",
difficulty_easy: "سهل",
difficulty_medium: "متوسط",
difficulty_hard: "صعب",
difficulty_easy_label: "سهل - المحدد مُعطى",
difficulty_medium_label: "متوسط - يتطلب محدد بسيط",
difficulty_hard_label: "صعب - يتطلب محدد مركب",
successMessage: "CRISPY! ٩(◕‿◕)۶ الكود يعمل بشكل صحيح.", successMessage: "CRISPY! ٩(◕‿◕)۶ الكود يعمل بشكل صحيح.",
keepTrying: "استمر في المحاولة!", keepTrying: "استمر في المحاولة!",
failedToLoad: "فشل تحميل الوحدات. قم بتحديث الصفحة.", failedToLoad: "فشل تحميل الوحدات. قم بتحديث الصفحة.",
@@ -1011,7 +1050,7 @@ const translations = {
// Landing page // Landing page
landingHeroTitle: "تعلم تطوير الويب", landingHeroTitle: "تعلم تطوير الويب",
landingHeroHighlight: "Crispy Code", landingHeroHighlight: "Code Crispy",
landingHeroSubtitle: "أتقن HTML و CSS و Tailwind من خلال تمارين عملية مع ملاحظات فورية. مجاني ومفتوح المصدر.", landingHeroSubtitle: "أتقن HTML و CSS و Tailwind من خلال تمارين عملية مع ملاحظات فورية. مجاني ومفتوح المصدر.",
landingCtaStart: "ابدأ الآن", landingCtaStart: "ابدأ الآن",
landingWhyTitle: "لماذا CODE CRISPIES فعال", landingWhyTitle: "لماذا CODE CRISPIES فعال",
@@ -1027,6 +1066,7 @@ const translations = {
landingCssDesc: "التنسيق والتخطيط والرسوم المتحركة", landingCssDesc: "التنسيق والتخطيط والرسوم المتحركة",
landingHtmlDesc: "الترميز الدلالي والعناصر الأصلية", landingHtmlDesc: "الترميز الدلالي والعناصر الأصلية",
landingTailwindDesc: "إطار CSS قائم على الأدوات", landingTailwindDesc: "إطار CSS قائم على الأدوات",
landingMarkdownDesc: "تنسيق النص بصيغة بسيطة",
comingSoon: "قريباً", comingSoon: "قريباً",
landingCtaTitle: "ابدأ التعلم اليوم", landingCtaTitle: "ابدأ التعلم اليوم",
landingCtaSub: "مجاني ومفتوح المصدر. لا حاجة لحساب. يُحفظ التقدم محليًا.", landingCtaSub: "مجاني ومفتوح المصدر. لا حاجة لحساب. يُحفظ التقدم محليًا.",
@@ -1152,6 +1192,7 @@ const translations = {
progress: "Прогрес", progress: "Прогрес",
progressText: "{percent}% завершено ({completed}/{total})", progressText: "{percent}% завершено ({completed}/{total})",
progressTextMilestone: "{completed} з {total} уроків завершено", progressTextMilestone: "{completed} з {total} уроків завершено",
progressTotal: "{total} уроків всього",
lessons: "Уроки", lessons: "Уроки",
settings: "Налаштування", settings: "Налаштування",
showHints: "Показувати підказки", showHints: "Показувати підказки",
@@ -1222,6 +1263,12 @@ const translations = {
// Dynamic content // Dynamic content
loadingFallbackText: "Не вдалося завантажити урок. Виберіть один з меню або перевірте допомогу.", loadingFallbackText: "Не вдалося завантажити урок. Виберіть один з меню або перевірте допомогу.",
completed: "Завершено", completed: "Завершено",
difficulty_easy: "Легко",
difficulty_medium: "Середнє",
difficulty_hard: "Складно",
difficulty_easy_label: "Легко - селектор наданий",
difficulty_medium_label: "Середнє - потрібен простий селектор",
difficulty_hard_label: "Складно - потрібен складений селектор",
successMessage: "CRISPY! ٩(◕‿◕)۶ Ваш код працює правильно.", successMessage: "CRISPY! ٩(◕‿◕)۶ Ваш код працює правильно.",
keepTrying: "Продовжуйте спроби!", keepTrying: "Продовжуйте спроби!",
failedToLoad: "Не вдалося завантажити модулі. Оновіть сторінку.", failedToLoad: "Не вдалося завантажити модулі. Оновіть сторінку.",
@@ -1231,7 +1278,7 @@ const translations = {
// Landing page // Landing page
landingHeroTitle: "Вивчай веб-розробку", landingHeroTitle: "Вивчай веб-розробку",
landingHeroHighlight: "Crispy Code", landingHeroHighlight: "Code Crispy",
landingHeroSubtitle: "Опануй HTML, CSS та Tailwind через практичні вправи з миттєвим зворотним зв'язком. Безкоштовно та з відкритим кодом.", landingHeroSubtitle: "Опануй HTML, CSS та Tailwind через практичні вправи з миттєвим зворотним зв'язком. Безкоштовно та з відкритим кодом.",
landingCtaStart: "Почни ЗАРАЗ", landingCtaStart: "Почни ЗАРАЗ",
landingWhyTitle: "Чому CODE CRISPIES працює", landingWhyTitle: "Чому CODE CRISPIES працює",
@@ -1248,6 +1295,7 @@ const translations = {
landingCssDesc: "Стилізація, макети та анімації", landingCssDesc: "Стилізація, макети та анімації",
landingHtmlDesc: "Семантична розмітка та нативні елементи", landingHtmlDesc: "Семантична розмітка та нативні елементи",
landingTailwindDesc: "CSS-фреймворк на основі утиліт", landingTailwindDesc: "CSS-фреймворк на основі утиліт",
landingMarkdownDesc: "Форматуй текст простим синтаксисом",
comingSoon: "Незабаром", comingSoon: "Незабаром",
landingCtaTitle: "Почни вчитися сьогодні", landingCtaTitle: "Почни вчитися сьогодні",
landingCtaSub: "Безкоштовно та з відкритим кодом. Без реєстрації. Прогрес зберігається локально.", landingCtaSub: "Безкоштовно та з відкритим кодом. Без реєстрації. Прогрес зберігається локально.",

View File

@@ -1,12 +1,14 @@
/** /**
* CodeEditor - CodeMirror 6 wrapper with Emmet support * CodeEditor - CodeMirror 6 wrapper with Emmet support
*/ */
import { EditorState, Prec } from "@codemirror/state"; import { EditorState, EditorSelection, Prec, StateField, Compartment } from "@codemirror/state";
import { EditorView, keymap, placeholder } from "@codemirror/view"; import { EditorView, keymap, placeholder, Decoration } from "@codemirror/view";
import { defaultKeymap, historyKeymap, indentMore, indentLess, undo, redo } from "@codemirror/commands"; import { defaultKeymap, historyKeymap, indentMore, indentLess, undo, redo } from "@codemirror/commands";
import { history } from "@codemirror/commands"; import { history } from "@codemirror/commands";
import { html } from "@codemirror/lang-html"; import { html } from "@codemirror/lang-html";
import { css } from "@codemirror/lang-css"; import { css } from "@codemirror/lang-css";
import { markdown } from "@codemirror/lang-markdown";
import { javascript } from "@codemirror/lang-javascript";
import { autocompletion } from "@codemirror/autocomplete"; import { autocompletion } from "@codemirror/autocomplete";
import { abbreviationTracker, expandAbbreviation } from "@emmetio/codemirror6-plugin"; import { abbreviationTracker, expandAbbreviation } from "@emmetio/codemirror6-plugin";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
@@ -146,17 +148,134 @@ export class CodeEditor {
this.mode = options.mode || "css"; this.mode = options.mode || "css";
this.section = options.section || null; this.section = options.section || null;
this.onChange = options.onChange || (() => {}); this.onChange = options.onChange || (() => {});
// Read-only zones support
this.prefixLength = 0;
this.suffixLength = 0;
this.currentPrefix = "";
this.currentSuffix = "";
this.readOnlyCompartment = new Compartment();
} }
/** /**
* Initialize the editor * Initialize the editor (backwards compatible wrapper)
*/ */
init(initialValue = "") { init(initialValue = "") {
return this.initWithContext("", initialValue, "");
}
/**
* Initialize the editor with read-only prefix/suffix zones
* @param {string} prefix - Read-only prefix text (e.g., ".card {\n ")
* @param {string} initialValue - Editable user code
* @param {string} suffix - Read-only suffix text (e.g., "\n}")
*/
initWithContext(prefix = "", initialValue = "", suffix = "") {
// Clear container // Clear container
this.container.innerHTML = ""; this.container.innerHTML = "";
// Store prefix/suffix for re-initialization (e.g., when mode changes)
this.currentPrefix = prefix;
this.currentSuffix = suffix;
this.prefixLength = prefix.length;
this.suffixLength = suffix.length;
const fullDoc = prefix + initialValue + suffix;
// Get language extension based on mode // Get language extension based on mode
const langExtension = this.mode === "html" ? html() : css(); const langExtension = this.mode === "html" ? html() : this.mode === "markdown" ? markdown() : this.mode === "javascript" ? javascript() : css();
// Create read-only zones decorations
const readOnlyMark = Decoration.mark({ class: "cm-readonly-zone" });
// StateField to track and provide decorations for read-only zones
const readOnlyDecorations = StateField.define({
create: (state) => {
const decorations = [];
if (this.prefixLength > 0) {
decorations.push(readOnlyMark.range(0, this.prefixLength));
}
if (this.suffixLength > 0) {
const suffixStart = state.doc.length - this.suffixLength;
decorations.push(readOnlyMark.range(suffixStart, state.doc.length));
}
return Decoration.set(decorations);
},
update: (decorations, tr) => {
if (!tr.docChanged) return decorations;
// Recalculate decorations after document changes
const newDecorations = [];
if (this.prefixLength > 0) {
newDecorations.push(readOnlyMark.range(0, this.prefixLength));
}
if (this.suffixLength > 0) {
const suffixStart = tr.state.doc.length - this.suffixLength;
newDecorations.push(readOnlyMark.range(suffixStart, tr.state.doc.length));
}
return Decoration.set(newDecorations);
},
provide: (f) => EditorView.decorations.from(f)
});
// Change filter to prevent edits in read-only zones
const readOnlyFilter = EditorState.changeFilter.of((tr) => {
// If no prefix/suffix, allow all changes
if (this.prefixLength === 0 && this.suffixLength === 0) {
return true;
}
const prefixEnd = this.prefixLength;
const suffixStart = tr.startState.doc.length - this.suffixLength;
// Check all change ranges - allow only changes within [prefixEnd, suffixStart]
let blocked = false;
tr.changes.iterChangedRanges((fromA, toA) => {
// Block if change starts in prefix zone
if (fromA < prefixEnd) {
blocked = true;
}
// Block if change extends into suffix zone
if (toA > suffixStart) {
blocked = true;
}
});
return !blocked;
});
// Transaction filter to constrain cursor/selection to editable area
const cursorFilter = EditorState.transactionFilter.of((tr) => {
// If no prefix/suffix, no constraints needed
if (this.prefixLength === 0 && this.suffixLength === 0) {
return tr;
}
const prefixEnd = this.prefixLength;
const suffixStart = tr.newDoc.length - this.suffixLength;
// Check if selection needs adjustment
const selection = tr.newSelection;
let needsAdjustment = false;
for (const range of selection.ranges) {
if (range.from < prefixEnd || range.to > suffixStart) {
needsAdjustment = true;
break;
}
}
if (!needsAdjustment) {
return tr;
}
// Clamp selection to editable area
const newRanges = selection.ranges.map((range) => {
const from = Math.max(prefixEnd, Math.min(suffixStart, range.from));
const to = Math.max(prefixEnd, Math.min(suffixStart, range.to));
return EditorSelection.range(from, to);
});
return [tr, { selection: EditorSelection.create(newRanges, selection.mainIndex) }];
});
// Build extensions array // Build extensions array
const extensions = [ const extensions = [
@@ -165,6 +284,10 @@ export class CodeEditor {
editorTheme, editorTheme,
// History for undo/redo // History for undo/redo
history(), history(),
// Read-only zones (decorations, change filter, and cursor constraint)
readOnlyDecorations,
readOnlyFilter,
cursorFilter,
// Emmet abbreviation tracking // Emmet abbreviation tracking
abbreviationTracker(), abbreviationTracker(),
// High priority keymap for Emmet // High priority keymap for Emmet
@@ -184,20 +307,21 @@ export class CodeEditor {
}), }),
EditorView.updateListener.of((update) => { EditorView.updateListener.of((update) => {
if (update.docChanged) { if (update.docChanged) {
this.onChange(this.getValue()); // Report only the editable portion to the onChange handler
this.onChange(this.getEditableValue());
} }
}), }),
EditorView.lineWrapping EditorView.lineWrapping
]; ];
// Add placeholder if provided // Add placeholder if provided (only makes sense when no prefix/suffix)
if (this.options.placeholder) { if (this.options.placeholder && this.prefixLength === 0 && this.suffixLength === 0) {
extensions.push(placeholder(this.options.placeholder)); extensions.push(placeholder(this.options.placeholder));
} }
// Create editor state // Create editor state
const state = EditorState.create({ const state = EditorState.create({
doc: initialValue, doc: fullDoc,
extensions extensions
}); });
@@ -207,26 +331,47 @@ export class CodeEditor {
parent: this.container parent: this.container
}); });
// Position cursor at start of editable area
if (this.prefixLength > 0) {
this.view.dispatch({
selection: { anchor: this.prefixLength }
});
}
return this; return this;
} }
/** /**
* Get current editor value * Get current full editor value (including prefix/suffix)
*/ */
getValue() { getValue() {
return this.view ? this.view.state.doc.toString() : ""; return this.view ? this.view.state.doc.toString() : "";
} }
/** /**
* Set editor value (preserves history) * Get only the editable portion (excluding prefix/suffix)
*/
getEditableValue() {
if (!this.view) return "";
const fullText = this.view.state.doc.toString();
const editableEnd = fullText.length - this.suffixLength;
return fullText.slice(this.prefixLength, editableEnd);
}
/**
* Set editor value in the editable zone only (preserves history)
*/ */
setValue(value) { setValue(value) {
if (!this.view) return; if (!this.view) return;
// Only replace the editable portion
const editableStart = this.prefixLength;
const editableEnd = this.view.state.doc.length - this.suffixLength;
this.view.dispatch({ this.view.dispatch({
changes: { changes: {
from: 0, from: editableStart,
to: this.view.state.doc.length, to: editableEnd,
insert: value insert: value
} }
}); });
@@ -234,9 +379,12 @@ export class CodeEditor {
/** /**
* Set editor value and clear history (for lesson switching) * Set editor value and clear history (for lesson switching)
* @param {string} value - The editable user code (not including prefix/suffix)
* @param {string} prefix - Optional read-only prefix
* @param {string} suffix - Optional read-only suffix
*/ */
setValueAndClearHistory(value) { setValueAndClearHistory(value, prefix = "", suffix = "") {
this.init(value); this.initWithContext(prefix, value, suffix);
} }
/** /**
@@ -246,8 +394,8 @@ export class CodeEditor {
if (this.mode === mode) return; if (this.mode === mode) return;
this.mode = mode; this.mode = mode;
const currentValue = this.getValue(); const editableValue = this.getEditableValue();
this.init(currentValue); this.initWithContext(this.currentPrefix, editableValue, this.currentSuffix);
} }
/** /**
@@ -257,8 +405,8 @@ export class CodeEditor {
if (this.section === section) return; if (this.section === section) return;
this.section = section; this.section = section;
const currentValue = this.getValue(); const editableValue = this.getEditableValue();
this.init(currentValue); this.initWithContext(this.currentPrefix, editableValue, this.currentSuffix);
} }
/** /**

View File

@@ -3,6 +3,7 @@
* Single source of truth for lesson state and progress * Single source of truth for lesson state and progress
*/ */
import { validateUserCode } from "../helpers/validator.js"; import { validateUserCode } from "../helpers/validator.js";
import { marked } from "marked";
// Auth sync - lazy loaded to avoid circular dependencies // Auth sync - lazy loaded to avoid circular dependencies
let authModule = null; let authModule = null;
@@ -255,6 +256,62 @@ export class LessonEngine {
${htmlWithClasses} ${htmlWithClasses}
</body> </body>
</html> </html>
`);
} else if (mode === "markdown") {
// For Markdown mode, parse user code to HTML
const renderedHtml = marked.parse(this.userCode || "");
iframeDoc.write(`
<!DOCTYPE html>
<html>
<head>
<style>html, body { min-height: 100%; margin: 0; }</style>
<style>${previewBaseCSS || ""}</style>
<style>
body { font-family: system-ui, sans-serif; line-height: 1.6; padding: 1rem; }
h1, h2, h3, h4, h5, h6 { margin: 1em 0 0.5em; line-height: 1.3; }
h1 { font-size: 2em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
h2 { font-size: 1.5em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
p { margin: 0.5em 0; }
ul, ol { margin: 0.5em 0; padding-left: 2em; }
li { margin: 0.25em 0; }
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-family: monospace; }
pre { background: #f4f4f4; padding: 1em; overflow-x: auto; border-radius: 4px; }
pre code { background: none; padding: 0; }
blockquote { margin: 0.5em 0; padding-left: 1em; border-left: 4px solid #ddd; color: #666; }
a { color: #0366d6; }
hr { border: none; border-top: 1px solid #eee; margin: 1em 0; }
img { max-width: 100%; }
</style>
<style>${sandboxCSS || ""}</style>
</head>
<body>
${renderedHtml}
</body>
</html>
`);
} else if (mode === "javascript") {
// For JavaScript mode, inject user code as a script
const { codePrefix, codeSuffix } = this.currentLesson;
const fullScript = `${codePrefix || ""}${this.userCode || ""}${codeSuffix || ""}`;
iframeDoc.write(`
<!DOCTYPE html>
<html>
<head>
<style>html, body { min-height: 100%; margin: 0; }</style>
<style>${previewBaseCSS || ""}</style>
<style>${sandboxCSS || ""}</style>
</head>
<body>
${previewHTML || ""}
<script>
try {
${fullScript}
} catch (e) {
console.error("Script error:", e);
}
</script>
</body>
</html>
`); `);
} else { } else {
// Original CSS mode // Original CSS mode
@@ -349,6 +406,62 @@ export class LessonEngine {
${htmlWithClasses} ${htmlWithClasses}
</body> </body>
</html> </html>
`);
} else if (mode === "markdown") {
// For Markdown mode, parse solution to HTML
const renderedHtml = marked.parse(solutionCode || "");
iframeDoc.write(`
<!DOCTYPE html>
<html>
<head>
<style>html, body { min-height: 100%; margin: 0; }</style>
<style>${previewBaseCSS || ""}</style>
<style>
body { font-family: system-ui, sans-serif; line-height: 1.6; padding: 1rem; }
h1, h2, h3, h4, h5, h6 { margin: 1em 0 0.5em; line-height: 1.3; }
h1 { font-size: 2em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
h2 { font-size: 1.5em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
p { margin: 0.5em 0; }
ul, ol { margin: 0.5em 0; padding-left: 2em; }
li { margin: 0.25em 0; }
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-family: monospace; }
pre { background: #f4f4f4; padding: 1em; overflow-x: auto; border-radius: 4px; }
pre code { background: none; padding: 0; }
blockquote { margin: 0.5em 0; padding-left: 1em; border-left: 4px solid #ddd; color: #666; }
a { color: #0366d6; }
hr { border: none; border-top: 1px solid #eee; margin: 1em 0; }
img { max-width: 100%; }
</style>
<style>${sandboxCSS || ""}</style>
</head>
<body>
${renderedHtml}
</body>
</html>
`);
} else if (mode === "javascript") {
// For JavaScript mode, inject solution code as a script
const { codePrefix, codeSuffix } = this.currentLesson;
const fullScript = `${codePrefix || ""}${solutionCode}${codeSuffix || ""}`;
iframeDoc.write(`
<!DOCTYPE html>
<html>
<head>
<style>html, body { min-height: 100%; margin: 0; }</style>
<style>${previewBaseCSS || ""}</style>
<style>${sandboxCSS || ""}</style>
</head>
<body>
${previewHTML || ""}
<script>
try {
${fullScript}
} catch (e) {
console.error("Script error:", e);
}
</script>
</body>
</html>
`); `);
} else { } else {
// CSS mode - wrap solution with prefix/suffix // CSS mode - wrap solution with prefix/suffix

View File

@@ -74,7 +74,9 @@
<nav class="main-nav" id="main-nav" aria-label="Main sections"> <nav class="main-nav" id="main-nav" aria-label="Main sections">
<a href="#css" class="nav-link" data-section="css">CSS</a> <a href="#css" class="nav-link" data-section="css">CSS</a>
<a href="#html" class="nav-link" data-section="html">HTML</a> <a href="#html" class="nav-link" data-section="html">HTML</a>
<a href="#tailwind" class="nav-link" data-section="tailwind">Tailwind</a> <!-- <a href="#tailwind" class="nav-link" data-section="tailwind">Tailwind</a> -->
<a href="#markdown" class="nav-link" data-section="markdown">Markdown</a>
<a href="#javascript" class="nav-link" data-section="javascript">JS</a>
<a href="#reference/css" class="nav-link nav-link-ref" data-section="reference">Reference</a> <a href="#reference/css" class="nav-link nav-link-ref" data-section="reference">Reference</a>
</nav> </nav>
<button id="auth-trigger-header" class="btn btn-outline btn-sm" data-i18n="authLogin">Log In</button> <button id="auth-trigger-header" class="btn btn-outline btn-sm" data-i18n="authLogin">Log In</button>
@@ -162,13 +164,30 @@
<p data-i18n="landingHtmlDesc">Semantic markup and native elements</p> <p data-i18n="landingHtmlDesc">Semantic markup and native elements</p>
<span class="section-card-progress" id="html-progress"></span> <span class="section-card-progress" id="html-progress"></span>
</a> </a>
<!-- Tailwind temporarily disabled
<a href="#tailwind" class="section-card" data-section="tailwind"> <a href="#tailwind" class="section-card" data-section="tailwind">
<div class="section-card-icon" style="background: #26a69a">TW</div> <div class="section-card-icon" style="background: #26a69a">TW</div>
<h3>Tailwind CSS</h3> <h3>Tailwind CSS</h3>
<p data-i18n="landingTailwindDesc">Utility-first CSS framework</p> <p data-i18n="landingTailwindDesc">Utility-first CSS framework</p>
<span class="section-card-progress" id="tailwind-progress"></span> <span class="section-card-progress" id="tailwind-progress"></span>
</a> </a>
-->
<a href="#markdown" class="section-card" data-section="markdown">
<div class="section-card-icon" style="background: #5b8dd9">MD</div>
<h3>Markdown</h3>
<p data-i18n="landingMarkdownDesc">Lightweight markup for formatting text</p>
<span class="section-card-progress" id="markdown-progress"></span>
</a>
<a href="#javascript" class="section-card" data-section="javascript">
<div class="section-card-icon" style="background: #f0c040">JS</div>
<h3>JavaScript</h3>
<p data-i18n="landingJsDesc">Variables, DOM manipulation, and event handling</p>
<span class="section-card-progress" id="javascript-progress"></span>
</a>
</div> </div>
<p class="device-notice" data-i18n-html="deviceNotice">
<strong>Best on desktop or tablet (landscape).</strong> Mobile works, but larger screens make coding easier.
</p>
</section> </section>
<section class="coming-soon"> <section class="coming-soon">
@@ -214,12 +233,6 @@
</div> </div>
</section> </section>
<section class="device-notice">
<p data-i18n-html="deviceNotice">
<strong>Best on desktop or tablet (landscape).</strong> Mobile works, but larger screens make coding easier.
</p>
</section>
<section class="landing-cta"> <section class="landing-cta">
<h2 data-i18n="landingCtaTitle">Start Learning Today</h2> <h2 data-i18n="landingCtaTitle">Start Learning Today</h2>
<a href="#welcome/0" class="cta-button" data-i18n="landingCtaButton">Let's get crispy!</a> <a href="#welcome/0" class="cta-button" data-i18n="landingCtaButton">Let's get crispy!</a>
@@ -255,7 +268,7 @@
</section> </section>
</div> </div>
<div class="footer-bottom"> <div class="footer-bottom">
<p>&copy; 2025 <a href="https://librete.ch">LibreTECH</a>. <span data-i18n="footerLicense">Open source under MIT License.</span></p> <p>&copy; <span class="current-year"></span> <a href="https://librete.ch">LibreTECH</a>. <span data-i18n="footerLicense">Open source under MIT License.</span></p>
<p class="footer-legal"> <p class="footer-legal">
<button type="button" class="btn-text privacy-link" data-i18n="footerPrivacy">Privacy Policy</button> <button type="button" class="btn-text privacy-link" data-i18n="footerPrivacy">Privacy Policy</button>
<span class="footer-separator">·</span> <span class="footer-separator">·</span>
@@ -312,7 +325,7 @@
</section> </section>
</div> </div>
<div class="footer-bottom"> <div class="footer-bottom">
<p>&copy; 2025 <a href="https://librete.ch">LibreTECH</a>. <span data-i18n="footerLicense">Open source under MIT License.</span></p> <p>&copy; <span class="current-year"></span> <a href="https://librete.ch">LibreTECH</a>. <span data-i18n="footerLicense">Open source under MIT License.</span></p>
<p class="footer-legal"> <p class="footer-legal">
<button type="button" class="btn-text privacy-link" data-i18n="footerPrivacy">Privacy Policy</button> <button type="button" class="btn-text privacy-link" data-i18n="footerPrivacy">Privacy Policy</button>
<span class="footer-separator">·</span> <span class="footer-separator">·</span>
@@ -331,6 +344,7 @@
<a href="#reference/flexbox" class="ref-nav-link" data-ref="flexbox">Flexbox</a> <a href="#reference/flexbox" class="ref-nav-link" data-ref="flexbox">Flexbox</a>
<a href="#reference/grid" class="ref-nav-link" data-ref="grid">Grid</a> <a href="#reference/grid" class="ref-nav-link" data-ref="grid">Grid</a>
<a href="#reference/html" class="ref-nav-link" data-ref="html">HTML Elements</a> <a href="#reference/html" class="ref-nav-link" data-ref="html">HTML Elements</a>
<a href="#reference/markdown" class="ref-nav-link" data-ref="markdown">Markdown</a>
</nav> </nav>
<div class="reference-body" id="reference-body"> <div class="reference-body" id="reference-body">
<!-- Reference content injected by JS --> <!-- Reference content injected by JS -->
@@ -365,7 +379,7 @@
</section> </section>
</div> </div>
<div class="footer-bottom"> <div class="footer-bottom">
<p>&copy; 2025 <a href="https://librete.ch">LibreTECH</a>. <span data-i18n="footerLicense">Open source under MIT License.</span></p> <p>&copy; <span class="current-year"></span> <a href="https://librete.ch">LibreTECH</a>. <span data-i18n="footerLicense">Open source under MIT License.</span></p>
<p class="footer-legal"> <p class="footer-legal">
<button type="button" class="btn-text privacy-link" data-i18n="footerPrivacy">Privacy Policy</button> <button type="button" class="btn-text privacy-link" data-i18n="footerPrivacy">Privacy Policy</button>
<span class="footer-separator">·</span> <span class="footer-separator">·</span>
@@ -466,6 +480,15 @@
<button id="close-sidebar" class="close-btn" data-i18n-aria-label="closeMenu" aria-label="Close menu">&times;</button> <button id="close-sidebar" class="close-btn" data-i18n-aria-label="closeMenu" aria-label="Close menu">&times;</button>
</div> </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> -->
<a href="#markdown" class="sidebar-nav-link" data-section="markdown">Markdown</a>
<a href="#javascript" class="sidebar-nav-link" data-section="javascript">JavaScript</a>
<button id="auth-trigger-mobile" class="sidebar-nav-link sidebar-auth-link" data-i18n="authLogin">Log In</button>
</nav>
<div class="sidebar-section"> <div class="sidebar-section">
<h4 data-i18n="progress">Progress</h4> <h4 data-i18n="progress">Progress</h4>
<div class="progress-display milestone-progress" id="progress-display"> <div class="progress-display milestone-progress" id="progress-display">
@@ -479,10 +502,13 @@
<span class="milestone" data-value="75">75</span> <span class="milestone" data-value="75">75</span>
<span class="milestone" data-value="100">100</span> <span class="milestone" data-value="100">100</span>
</div> </div>
<div class="progress-bar"> <div class="progress-bar-row">
<div class="progress-fill" id="progress-fill"></div> <div class="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
</div>
<span class="progress-current" id="progress-current">0/1</span>
</div> </div>
<span class="progress-text" id="progress-text">0 of 100</span> <span class="progress-total" id="progress-total">0 of 100 lessons</span>
</div> </div>
</div> </div>
@@ -504,23 +530,27 @@
<div class="sidebar-section"> <div class="sidebar-section">
<h4 data-i18n="settings">Settings</h4> <h4 data-i18n="settings">Settings</h4>
<label class="setting-row"> <div class="settings-card">
<span class="setting-label" data-i18n="language">Language</span> <label class="settings-row">
<select id="lang-select" class="lang-select"> <span class="settings-label" data-i18n="language">Language</span>
<option value="en">English</option> <select id="lang-select" class="lang-select">
<option value="de">Deutsch</option> <option value="en">English</option>
<option value="pl">Polski</option> <option value="de">Deutsch</option>
<option value="es">Español</option> <option value="pl">Polski</option>
<option value="ar">العربية</option> <option value="es">Español</option>
<option value="uk">Українська</option> <option value="ar">العربية</option>
</select> <option value="uk">Українська</option>
</label> </select>
<label class="toggle-switch"> </label>
<input type="checkbox" id="disable-feedback-toggle" checked /> <label class="settings-row">
<span class="toggle-slider"></span> <span class="settings-label" data-i18n="showHints">Show Hints</span>
<span class="toggle-label" data-i18n="showHints">Show Hints</span> <input type="checkbox" id="disable-feedback-toggle" class="settings-toggle" checked />
</label> </label>
<button id="reset-btn" class="btn btn-text" data-i18n="resetAllProgress">Reset All Progress</button> <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> </div>
<footer class="app-footer"> <footer class="app-footer">

View File

@@ -283,6 +283,22 @@ kbd {
background: #1aafb8; background: #1aafb8;
} }
[data-section="markdown"] .logo h1 .code-text {
color: #5b8dd9;
}
[data-section="markdown"] .logo h1 .crispies-text {
background: #5b8dd9;
}
[data-section="javascript"] .logo h1 .code-text {
color: #d4a020;
}
[data-section="javascript"] .logo h1 .crispies-text {
background: #d4a020;
}
.help-toggle { .help-toggle {
width: 28px; width: 28px;
height: 28px; height: 28px;
@@ -308,6 +324,16 @@ kbd {
gap: var(--spacing-sm); gap: var(--spacing-sm);
} }
#auth-trigger-header {
display: none;
}
@media (min-width: 769px) {
#auth-trigger-header {
display: inline-flex;
}
}
/* ================= GAME LAYOUT ================= */ /* ================= GAME LAYOUT ================= */
.game-layout { .game-layout {
display: flex; display: flex;
@@ -374,6 +400,7 @@ kbd {
gap: 0.5rem; gap: 0.5rem;
background: var(--primary-bg-medium); background: var(--primary-bg-medium);
color: var(--primary-color); color: var(--primary-color);
min-width: 0;
padding: 4px 12px; padding: 4px 12px;
border-radius: 16px; border-radius: 16px;
font-size: 0.8rem; font-size: 0.8rem;
@@ -385,12 +412,18 @@ kbd {
.module-name { .module-name {
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
} }
.module-pill .level-indicator { .module-pill .level-indicator {
color: var(--primary-dark); color: var(--primary-dark);
font-weight: 500; font-weight: 500;
opacity: 0.8; opacity: 0.8;
white-space: nowrap;
flex-shrink: 0;
} }
.lesson-title-row { .lesson-title-row {
@@ -398,7 +431,13 @@ kbd {
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: var(--spacing-sm); 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 { #lesson-title {
@@ -447,6 +486,28 @@ kbd {
vertical-align: middle; 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 { .lesson-description {
font-size: 0.95rem; font-size: 0.95rem;
line-height: 1.6; line-height: 1.6;
@@ -548,6 +609,12 @@ kbd {
height: 100%; height: 100%;
} }
/* Read-only zones (codePrefix/codeSuffix) */
.cm-readonly-zone {
opacity: 0.5;
background: rgba(100, 100, 120, 0.1);
}
.editor-content .cm-scroller { .editor-content .cm-scroller {
overflow: auto; overflow: auto;
} }
@@ -893,8 +960,9 @@ kbd {
/* ================= GAME CONTROLS ================= */ /* ================= GAME CONTROLS ================= */
.game-controls { .game-controls {
display: flex; display: grid;
justify-content: space-between; grid-template-columns: 1fr auto 1fr;
gap: var(--spacing-md);
align-items: center; align-items: center;
padding: var(--spacing-md); padding: var(--spacing-md);
background: var(--panel-bg); background: var(--panel-bg);
@@ -902,8 +970,16 @@ kbd {
box-shadow: 0 10px 20px -10px rgba(0, 0, 0, 0.08); box-shadow: 0 10px 20px -10px rgba(0, 0, 0, 0.08);
} }
.game-controls.centered { .game-controls #prev-btn {
justify-content: center; justify-self: start;
}
.game-controls .module-pill {
justify-self: center;
}
.game-controls #next-btn {
justify-self: end;
} }
/* ================= SIDEBAR ================= */ /* ================= SIDEBAR ================= */
@@ -987,14 +1063,69 @@ kbd {
border-bottom: none; 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 */ /* Make the lessons nav section fill available space */
nav.sidebar-section { nav.sidebar-section:not(.sidebar-nav-mobile) {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
min-height: 0; min-height: 0;
padding-bottom: var(--spacing-md); padding-bottom: var(--spacing-md);
} }
.sidebar-nav-mobile {
flex: none;
padding: 0;
}
.sidebar-section h4 { .sidebar-section h4 {
font-size: 0.75rem; font-size: 0.75rem;
text-transform: uppercase; text-transform: uppercase;
@@ -1031,6 +1162,28 @@ nav.sidebar-section {
color: var(--light-text); color: var(--light-text);
} }
.progress-bar-row {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.progress-bar-row .progress-bar {
flex: 1;
}
.progress-current {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-color);
white-space: nowrap;
}
.progress-total {
font-size: 0.75rem;
color: var(--light-text);
}
/* Milestone Progress */ /* Milestone Progress */
.milestone-progress { .milestone-progress {
gap: var(--spacing-sm); gap: var(--spacing-sm);
@@ -1060,15 +1213,16 @@ nav.sidebar-section {
color: white; color: white;
} }
/* Each milestone gets a portion of the gradient based on position */ /* Each milestone gets a color evenly distributed across the gradient
.milestone.reached:nth-child(1) { background: #9163b8; } Gradient: #9163b8 (0%) → #d45aa0 (33%) → #1aafb8 (67%) → #7c4dff (100%) */
.milestone.reached:nth-child(2) { background: linear-gradient(135deg, #9163b8, #a85dac); } .milestone.reached:nth-child(1) { background: #a55eac; } /* ~14% */
.milestone.reached:nth-child(3) { background: linear-gradient(135deg, #9163b8, #d45aa0); } .milestone.reached:nth-child(2) { background: #c459a2; } /* ~28% */
.milestone.reached:nth-child(4) { background: linear-gradient(135deg, #9163b8, #d45aa0, #e87aac); } .milestone.reached:nth-child(3) { background: #d45aa0; } /* ~33% pink */
.milestone.reached:nth-child(5) { background: linear-gradient(135deg, #9163b8, #d45aa0, #1aafb8); } .milestone.reached:nth-child(4) { background: #a874a8; } /* ~43% */
.milestone.reached:nth-child(6) { background: linear-gradient(135deg, #9163b8, #d45aa0, #1aafb8, #4b8ecc); } .milestone.reached:nth-child(5) { background: #7785ac; } /* ~50% */
.milestone.reached:nth-child(7) { background: linear-gradient(135deg, #9163b8, #d45aa0, #1aafb8, #7c4dff); } .milestone.reached:nth-child(6) { background: #33a3b6; } /* ~62% */
.milestone.reached:nth-child(8) { background: linear-gradient(135deg, #9163b8, #d45aa0, #1aafb8, #7c4dff); } .milestone.reached:nth-child(7) { background: #4889d8; } /* ~80% */
.milestone.reached:nth-child(8) { background: #7c4dff; } /* 100% */
.milestone.current { .milestone.current {
color: white; color: white;
@@ -1103,6 +1257,17 @@ nav.sidebar-section {
/* No max-height - parent nav.sidebar-section handles overflow */ /* No max-height - parent nav.sidebar-section handles overflow */
} }
.module-section-header {
font-size: 0.7rem;
text-transform: uppercase;
color: #888;
letter-spacing: 0.08em;
padding: 0.75rem var(--spacing-sm) 0.25rem;
margin: 0;
font-weight: 600;
pointer-events: none;
}
.module-container { .module-container {
margin-bottom: 4px; margin-bottom: 4px;
} }
@@ -1357,8 +1522,63 @@ button.lesson-list-item {
display: none; display: none;
} }
/* ================= TOGGLE SWITCH ================= */ /* ================= SETTINGS CARD ================= */
/* Setting row (for label + control) */ .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 { .setting-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2388,17 +2608,11 @@ input:checked + .toggle-slider::before {
.device-notice { .device-notice {
margin-top: var(--spacing-lg); margin-top: var(--spacing-lg);
text-align: center; text-align: center;
padding: 1rem;
}
.device-notice p {
display: inline-block;
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, rgba(145, 99, 184, 0.1), rgba(212, 90, 160, 0.1), rgba(26, 175, 184, 0.1)); background: linear-gradient(135deg, rgba(145, 99, 184, 0.1), rgba(212, 90, 160, 0.1), rgba(26, 175, 184, 0.1));
border-radius: var(--border-radius-md); border-radius: var(--border-radius-md);
color: var(--light-text); color: var(--light-text);
font-size: 0.9rem; font-size: 0.9rem;
margin: 0;
} }
.device-notice strong { .device-notice strong {
@@ -3152,11 +3366,16 @@ input:checked + .toggle-slider::before {
.module-pill { .module-pill {
flex: 1; flex: 1;
min-width: 0;
max-width: 100%;
margin: 0 var(--spacing-sm); margin: 0 var(--spacing-sm);
justify-content: center; justify-content: center;
overflow: hidden;
} }
.module-name { .module-name {
flex: 1;
min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -3410,6 +3629,22 @@ input:checked + .toggle-slider::before {
--section-color-rgb: 26, 175, 184; --section-color-rgb: 26, 175, 184;
} }
/* Markdown Section - Blue */
[data-section="markdown"] {
--section-color: #5b8dd9;
--section-color-light: #7ba3e5;
--section-color-dark: #4070b8;
--section-color-rgb: 91, 141, 217;
}
/* JavaScript Section - Gold */
[data-section="javascript"] {
--section-color: #d4a020;
--section-color-light: #e0b840;
--section-color-dark: #b08818;
--section-color-rgb: 212, 160, 32;
}
/* Apply section colors to nav links */ /* Apply section colors to nav links */
.nav-link[data-section="css"] { .nav-link[data-section="css"] {
color: #9163b8; color: #9163b8;
@@ -3423,6 +3658,14 @@ input:checked + .toggle-slider::before {
color: #1aafb8; color: #1aafb8;
} }
.nav-link[data-section="markdown"] {
color: #5b8dd9;
}
.nav-link[data-section="javascript"] {
color: #d4a020;
}
.nav-link[data-section="css"]:hover, .nav-link[data-section="css"]:hover,
.nav-link[data-section="css"].active { .nav-link[data-section="css"].active {
background: rgba(145, 99, 184, 0.1); background: rgba(145, 99, 184, 0.1);
@@ -3441,6 +3684,18 @@ input:checked + .toggle-slider::before {
color: #0d8f96; color: #0d8f96;
} }
.nav-link[data-section="markdown"]:hover,
.nav-link[data-section="markdown"].active {
background: rgba(91, 141, 217, 0.1);
color: #4070b8;
}
.nav-link[data-section="javascript"]:hover,
.nav-link[data-section="javascript"].active {
background: rgba(212, 160, 32, 0.1);
color: #b08818;
}
/* Hint section colors */ /* Hint section colors */
body[data-section="css"] .hint { body[data-section="css"] .hint {
background: rgba(145, 99, 184, 0.3); background: rgba(145, 99, 184, 0.3);
@@ -3469,6 +3724,24 @@ body[data-section="tailwind"] .hint-progress {
background: #1aafb8; background: #1aafb8;
} }
body[data-section="markdown"] .hint {
background: rgba(91, 141, 217, 0.3);
border-left-color: #7ba3e5;
}
body[data-section="markdown"] .hint-progress {
background: #5b8dd9;
}
body[data-section="javascript"] .hint {
background: rgba(212, 160, 32, 0.3);
border-left-color: #e0b840;
}
body[data-section="javascript"] .hint-progress {
background: #d4a020;
}
/* RTL hint border */ /* RTL hint border */
[dir="rtl"] body[data-section="css"] .hint { [dir="rtl"] body[data-section="css"] .hint {
border-right-color: #a98cd6; border-right-color: #a98cd6;
@@ -3482,6 +3755,14 @@ body[data-section="tailwind"] .hint-progress {
border-right-color: #4db6ac; border-right-color: #4db6ac;
} }
[dir="rtl"] body[data-section="markdown"] .hint {
border-right-color: #7ba3e5;
}
[dir="rtl"] body[data-section="javascript"] .hint {
border-right-color: #e0b840;
}
/* Reference nav link colors */ /* Reference nav link colors */
.ref-nav-link[data-ref="css"], .ref-nav-link[data-ref="css"],
.ref-nav-link[data-ref="selectors"], .ref-nav-link[data-ref="selectors"],
@@ -3567,6 +3848,42 @@ body[data-section="tailwind"] .cm-editor .cm-activeLine {
background-color: rgba(26, 175, 184, 0.08) !important; background-color: rgba(26, 175, 184, 0.08) !important;
} }
body[data-section="markdown"] .cm-editor .cm-content {
caret-color: #5b8dd9 !important;
}
body[data-section="markdown"] .cm-editor .cm-cursor,
body[data-section="markdown"] .cm-editor .cm-dropCursor {
border-left-color: #5b8dd9 !important;
}
body[data-section="markdown"] .cm-editor .cm-selectionBackground,
body[data-section="markdown"] .cm-editor .cm-content ::selection {
background-color: rgba(91, 141, 217, 0.25) !important;
}
body[data-section="markdown"] .cm-editor .cm-activeLine {
background-color: rgba(91, 141, 217, 0.08) !important;
}
body[data-section="javascript"] .cm-editor .cm-content {
caret-color: #d4a020 !important;
}
body[data-section="javascript"] .cm-editor .cm-cursor,
body[data-section="javascript"] .cm-editor .cm-dropCursor {
border-left-color: #d4a020 !important;
}
body[data-section="javascript"] .cm-editor .cm-selectionBackground,
body[data-section="javascript"] .cm-editor .cm-content ::selection {
background-color: rgba(212, 160, 32, 0.25) !important;
}
body[data-section="javascript"] .cm-editor .cm-activeLine {
background-color: rgba(212, 160, 32, 0.08) !important;
}
/* Module pill section colors */ /* Module pill section colors */
body[data-section="css"] .module-pill { body[data-section="css"] .module-pill {
background: rgba(145, 99, 184, 0.1); background: rgba(145, 99, 184, 0.1);
@@ -3595,6 +3912,24 @@ body[data-section="tailwind"] .module-pill .level-indicator {
color: #0d8f96; color: #0d8f96;
} }
body[data-section="markdown"] .module-pill {
background: rgba(91, 141, 217, 0.1);
color: #5b8dd9;
}
body[data-section="markdown"] .module-pill .level-indicator {
color: #4070b8;
}
body[data-section="javascript"] .module-pill {
background: rgba(212, 160, 32, 0.1);
color: #d4a020;
}
body[data-section="javascript"] .module-pill .level-indicator {
color: #b08818;
}
/* Code block border section colors */ /* Code block border section colors */
body[data-section="css"] .code-block { body[data-section="css"] .code-block {
border-color: rgba(145, 99, 184, 0.4); border-color: rgba(145, 99, 184, 0.4);
@@ -3608,6 +3943,14 @@ body[data-section="tailwind"] .code-block {
border-color: rgba(26, 175, 184, 0.4); border-color: rgba(26, 175, 184, 0.4);
} }
body[data-section="markdown"] .code-block {
border-color: rgba(91, 141, 217, 0.4);
}
body[data-section="javascript"] .code-block {
border-color: rgba(212, 160, 32, 0.4);
}
/* Section code block CodeMirror syntax highlighting overrides */ /* Section code block CodeMirror syntax highlighting overrides */
body[data-section="css"] .code-block .cm-editor .cm-line { body[data-section="css"] .code-block .cm-editor .cm-line {
color: #c9c0e0; color: #c9c0e0;
@@ -3621,6 +3964,14 @@ body[data-section="tailwind"] .code-block .cm-editor .cm-line {
color: #c0e0e8; color: #c0e0e8;
} }
body[data-section="markdown"] .code-block .cm-editor .cm-line {
color: #c0d0e8;
}
body[data-section="javascript"] .code-block .cm-editor .cm-line {
color: #e8dcc0;
}
/* Task instruction bubble section colors */ /* Task instruction bubble section colors */
[data-section="css"] .task-instruction { [data-section="css"] .task-instruction {
background: rgba(145, 99, 184, 0.92); background: rgba(145, 99, 184, 0.92);
@@ -3634,6 +3985,14 @@ body[data-section="tailwind"] .code-block .cm-editor .cm-line {
background: rgba(26, 175, 184, 0.92); background: rgba(26, 175, 184, 0.92);
} }
[data-section="markdown"] .task-instruction {
background: rgba(91, 141, 217, 0.92);
}
[data-section="javascript"] .task-instruction {
background: rgba(212, 160, 32, 0.92);
}
/* Section page progress bar colors */ /* Section page progress bar colors */
body[data-section="css"] .section-progress-bar .progress-fill { body[data-section="css"] .section-progress-bar .progress-fill {
background: #9163b8; background: #9163b8;
@@ -3647,6 +4006,14 @@ body[data-section="tailwind"] .section-progress-bar .progress-fill {
background: #1aafb8; background: #1aafb8;
} }
body[data-section="markdown"] .section-progress-bar .progress-fill {
background: #5b8dd9;
}
body[data-section="javascript"] .section-progress-bar .progress-fill {
background: #d4a020;
}
/* Section page header colors */ /* Section page header colors */
[data-section="css"] .section-hero h1 { [data-section="css"] .section-hero h1 {
color: #9163b8; color: #9163b8;
@@ -3660,6 +4027,14 @@ body[data-section="tailwind"] .section-progress-bar .progress-fill {
color: #1aafb8; color: #1aafb8;
} }
[data-section="markdown"] .section-hero h1 {
color: #5b8dd9;
}
[data-section="javascript"] .section-hero h1 {
color: #d4a020;
}
/* Lesson title h2 section colors */ /* Lesson title h2 section colors */
body[data-section="css"] #lesson-title { body[data-section="css"] #lesson-title {
color: #9163b8; color: #9163b8;
@@ -3673,6 +4048,14 @@ body[data-section="tailwind"] #lesson-title {
color: #1aafb8; color: #1aafb8;
} }
body[data-section="markdown"] #lesson-title {
color: #5b8dd9;
}
body[data-section="javascript"] #lesson-title {
color: #d4a020;
}
/* Section and Reference footer - override landing-footer styles */ /* Section and Reference footer - override landing-footer styles */
.section-footer.landing-footer, .section-footer.landing-footer,
.reference-footer.landing-footer { .reference-footer.landing-footer {

View File

@@ -27,7 +27,35 @@ describe("Lessons Config Module", () => {
modules.forEach((module) => { modules.forEach((module) => {
module.lessons.forEach((lesson) => { module.lessons.forEach((lesson) => {
expect(lesson.mode).toBeDefined(); expect(lesson.mode).toBeDefined();
expect(["html", "css", "tailwind", "playground"]).toContain(lesson.mode); expect(["html", "css", "tailwind", "markdown", "javascript", "playground"]).toContain(lesson.mode);
});
});
});
});
describe("JavaScript modules", () => {
test("should include JavaScript modules", async () => {
const modules = await loadModules();
const moduleIds = modules.map((m) => m.id);
expect(moduleIds).toContain("js-variables");
expect(moduleIds).toContain("js-dom");
expect(moduleIds).toContain("js-events");
});
test("JavaScript modules should have correct mode and structure", async () => {
const modules = await loadModules();
const jsModules = modules.filter((m) => m.mode === "javascript");
expect(jsModules.length).toBe(3);
jsModules.forEach((module) => {
expect(module.lessons.length).toBeGreaterThanOrEqual(3);
module.lessons.forEach((lesson) => {
expect(lesson.mode).toBe("javascript");
expect(lesson.validations.length).toBeGreaterThan(0);
expect(lesson.task).toBeTruthy();
expect(lesson.solution).toBeTruthy();
}); });
}); });
}); });

View File

@@ -1,5 +1,5 @@
import { describe, test, expect, vi, beforeEach } from "vitest"; 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", () => { describe("Renderer Module", () => {
beforeEach(() => { beforeEach(() => {
@@ -176,4 +176,131 @@ describe("Renderer Module", () => {
clearFeedback(); 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");
});
});
describe("renderModuleList section headers", () => {
const noop = () => {};
test("inserts section header elements between different category groups", () => {
const container = document.getElementById("module-list");
const modules = [
{ id: "css-basic-selectors", title: "CSS Selectors", lessons: [{ title: "L1" }] },
{ id: "colors", title: "Colors", lessons: [{ title: "L1" }] },
{ id: "flexbox", title: "Flexbox", lessons: [{ title: "L1" }] },
{ id: "html-elements", title: "HTML Elements", lessons: [{ title: "L1" }] }
];
renderModuleList(container, modules, noop, noop);
const headers = container.querySelectorAll(".module-section-header");
expect(headers.length).toBe(3); // CSS Basics, CSS Layout, HTML Structure
});
test("section headers display correct category text", () => {
const container = document.getElementById("module-list");
const modules = [
{ id: "css-basic-selectors", title: "CSS Selectors", lessons: [{ title: "L1" }] },
{ id: "flexbox", title: "Flexbox", lessons: [{ title: "L1" }] }
];
renderModuleList(container, modules, noop, noop);
const headers = container.querySelectorAll(".module-section-header");
expect(headers[0].textContent).toBe("CSS Basics");
expect(headers[1].textContent).toBe("CSS Layout");
});
test("no section header is inserted between modules in the same category", () => {
const container = document.getElementById("module-list");
const modules = [
{ id: "css-basic-selectors", title: "CSS Selectors", lessons: [{ title: "L1" }] },
{ id: "colors", title: "Colors", lessons: [{ title: "L1" }] },
{ id: "typography", title: "Typography", lessons: [{ title: "L1" }] }
];
renderModuleList(container, modules, noop, noop);
const headers = container.querySelectorAll(".module-section-header");
expect(headers.length).toBe(1);
expect(headers[0].textContent).toBe("CSS Basics");
});
test("Welcome and Outro modules have no section headers", () => {
const container = document.getElementById("module-list");
const modules = [
{ id: "welcome", title: "Welcome", lessons: [{ title: "L1" }] },
{ id: "css-basic-selectors", title: "CSS Selectors", lessons: [{ title: "L1" }] },
{ id: "playground", title: "Playground", lessons: [{ title: "L1" }] }
];
renderModuleList(container, modules, noop, noop);
const headers = container.querySelectorAll(".module-section-header");
expect(headers.length).toBe(1);
expect(headers[0].textContent).toBe("CSS Basics");
});
});
}); });

View File

@@ -226,6 +226,69 @@ describe("CSS Validator", () => {
}); });
}); });
describe("JavaScript Validator", () => {
describe("validateUserCode with mode: javascript", () => {
it("should pass contains validation for correct code", () => {
const userCode = 'const name = "Ada";';
const lesson = {
mode: "javascript",
validations: [{ type: "contains", value: "const", message: "Use const" }]
};
const result = validateUserCode(userCode, lesson);
expect(result.isValid).toBe(true);
});
it("should fail contains validation for missing code", () => {
const userCode = 'var name = "Ada";';
const lesson = {
mode: "javascript",
validations: [{ type: "contains", value: "const", message: "Use const keyword" }]
};
const result = validateUserCode(userCode, lesson);
expect(result.isValid).toBe(false);
expect(result.message).toBe("Use const keyword");
});
it("should pass regex validation", () => {
const userCode = 'const name = "Ada";';
const lesson = {
mode: "javascript",
validations: [{ type: "regex", value: "const\\s+name\\s*=", message: "Declare name" }]
};
const result = validateUserCode(userCode, lesson);
expect(result.isValid).toBe(true);
});
it("should handle not_contains validation", () => {
const userCode = "let score = 0;";
const lesson = {
mode: "javascript",
validations: [{ type: "not_contains", value: "var", message: "Don't use var" }]
};
const result = validateUserCode(userCode, lesson);
expect(result.isValid).toBe(true);
const failLesson = {
mode: "javascript",
validations: [{ type: "not_contains", value: "let", message: "Don't use let" }]
};
const failResult = validateUserCode(userCode, failLesson);
expect(failResult.isValid).toBe(false);
});
it("should pass with no validations", () => {
const result = validateUserCode("const x = 1;", { mode: "javascript" });
expect(result.isValid).toBe(true);
expect(result.message).toContain("No validations specified");
});
});
});
describe("HTML Validator", () => { describe("HTML Validator", () => {
describe("validateUserCode with mode: html", () => { describe("validateUserCode with mode: html", () => {
it("should validate element_exists correctly", () => { it("should validate element_exists correctly", () => {