41 Commits

Author SHA1 Message Date
8c96d6aa64 fix: implement #12 — rewrite CSS Filters tasks to describe visual outcomes 2026-03-29 23:27:43 +02:00
b25e6a4e20 docs: add spec, plan, and tasks for filters task wording fix (#12) 2026-03-29 23:24:32 +02:00
c5641a8364 fix: revert primary and section colors back to original lilac/purple
The impl-feature pipeline changed the default color scheme from
lilac/purple (#5e4b8b, #9163b8) to pink (#c9507a, #d95a8a).
This was unintentional — lilac was the chosen brand color.

Reverted: primary, section defaults, bg tints, success colors,
gradients, nav highlights, and all hardcoded rgba values back to
the original purple palette.
2026-03-29 22:22:23 +02:00
00e9bd18e5 Merge pull request 'fix: rewrite colors and box-model tasks to describe outcomes instead of answers' (#10) from 009-colors-boxmodel-tasks into main 2026-03-29 00:01:08 +01:00
1baff9075c feat: implement #9 — rewrite colors and box-model task descriptions
Rewrite task text in Colors (4 lessons) and Box Model (8 lessons x 6
locales) to describe visual outcomes instead of giving copy-paste CSS
answers. Colors validations changed from property_value to regex
accepting multiple valid named colors per lesson.
2026-03-28 23:56:55 +01:00
3d6ff645fe docs: add spec, plan, and tasks for colors and box-model task wording fix (#9) 2026-03-28 23:51:13 +01:00
dc048eba4e Merge pull request 'feat: add JavaScript learning section with starter lessons and sidebar section headers' (#8) from feat/impl-feature-20260328-200101-ecae into main 2026-03-28 21:32:38 +01:00
05a683388b Merge pull request 'fix: rewrite validation messages to guide learning instead of revealing answers' (#7) from 004-validation-messages into main 2026-03-28 21:32:31 +01:00
ae8f9fef45 feat: add JavaScript learning section with starter lessons and sidebar section headers
Implementation following plan:
- S01: Foundation: schema, section config, and router
- S02: Install CodeMirror JavaScript language support
- S03: Create JavaScript lesson JSON files (variables, DOM, events)
- S04: Register JavaScript lessons in module stores
- S05: Add JavaScript validation logic
- S06: Add JavaScript mode to LessonEngine preview rendering
- S07: Add JavaScript mode to CodeEditor
- S08: Update app.js for JavaScript mode support
- S09: Update navigation HTML and CSS theming for JavaScript section
- S10: Add section grouping headers in sidebar navigation
- S11: Update and write tests
2026-03-28 20:22:50 +01:00
8d567390e5 fix: rewrite validation messages to guide learning instead of revealing answers (#4) 2026-03-28 20:10:04 +01:00
372320b807 Merge pull request 'fix: rewrite flexbox tasks to describe outcomes instead of answers' (#5) from 003-flexbox-task-wording into main 2026-03-28 19:59:24 +01:00
61acd692f4 fix: rewrite flexbox task descriptions to describe outcomes instead of answers (#3)
Replace copy-pasteable CSS declarations in all 6 flexbox lesson tasks with
outcome-oriented descriptions. Update validation error messages to hint at
properties without revealing exact declarations. Add regex validation for
flexbox-6 to accept both flex: 1 and flex-grow: 1.
2026-03-28 19:25:32 +01:00
672a2d28cb docs: add spec, plan, and tasks for flexbox task wording fix (#3) 2026-03-28 19:17:48 +01:00
433379155b fix(security): add Content-Security-Policy meta tag
Restricts script sources to self and known CDNs, connect sources to
self and Supabase, blocks unauthorized resource loading. Allows
unsafe-inline for styles (CodeMirror requirement) and blob: for
sandboxed preview iframes.

Addresses SEC-5 (HIGH) from security audit.
2026-03-28 17:01:32 +01:00
756841f8c2 fix(security): sandbox preview iframes to prevent XSS
Add sandbox='allow-scripts' to all preview iframes. This isolates
user-executed code from the parent page's localStorage (auth tokens),
cookies, and DOM. Switch from document.write() to srcdoc attribute
since sandboxed iframes can't use document.write().

Addresses SEC-1 (critical) from security audit.
2026-03-28 16:38:56 +01:00
c97fce1f29 fix: replace github personas with gitea, add ontology telos 2026-03-28 16:19:15 +01:00
8b6a88ad59 test: add 182 new tests for router, sections, renderer, and validator
Generated by wave test-gen pipeline. Coverage:
- router.js: 0% → ~85% (33 tests, all 7 exports)
- sections.js: 0% → ~90% (29 tests, all 5 exports)
- renderer.js: partial → extended (36 tests, difficulty, feedback, sidebar)
- validator.js: partial → extended (84 tests, all types + edge cases)

Total: 43 → 225 tests
2026-03-28 16:14:52 +01:00
4476d26140 chore: update wave.yaml to latest wave version with corrected project config 2026-03-28 15:58:46 +01:00
f28531fb4c ignore auto claude files 2026-01-27 16:53:47 +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
52 changed files with 5026 additions and 354 deletions

View File

@@ -1,15 +1,6 @@
{
"permissions": {
"allow": [
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(npm run build:*)",
"Bash(grep:*)",
"Bash(npm run format.lessons:*)",
"Bash(xargs:*)",
"Bash(cat:*)",
"Bash(prettier --write:*)"
],
"deny": ["Read(./.env)", "Read(./.env.*)", "Read(./secrets/**)"]
},

13
.gitignore vendored
View File

@@ -7,4 +7,15 @@ coverage
.env.local
# Claude Code local settings (user-specific)
.claude/settings.local.json
.claude/settings.local.json
.claude_settings.json
# Auto-Claude
.auto-claude
.worktrees
# Wave ephemeral data
.wave/workspaces
.wave/traces
.wave/artifacts
.wave/output

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 }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
pkgs = import nixpkgs { inherit system; config.allowUnfree = true; };
in {
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
nodejs_20
nodePackages.npm
gnumake
claude-code
];
shellHook = ''

View File

@@ -9,7 +9,7 @@
"id": "box-model-1",
"title": "Padding",
"description": "Every element in CSS is a box with four layers: content, padding, border, and margin. <strong>Padding</strong> creates breathing room between your content and the box's edge.<br><br>Without padding, text presses against borders awkwardly. Padding makes content readable and visually balanced.<br><br><pre>.card {\n padding: 1rem;\n}</pre>",
"task": "This profile card looks cramped. Add <kbd>padding: 1rem</kbd> to <kbd>.card</kbd> so the text has room to breathe.",
"task": "The text inside this profile card is pressed right against the edges. Give it some inner breathing room.",
"previewHTML": "<article class=\"card\"><h3>Sarah Chen</h3><p>Frontend Developer</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } .card h3 { margin: 0 0 4px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -22,7 +22,7 @@
{
"type": "property_value",
"value": { "property": "padding", "expected": "1rem" },
"message": "Set <kbd>padding: 1rem</kbd>"
"message": "Which property adds space between content and the element's edge?"
}
]
},
@@ -30,7 +30,7 @@
"id": "box-model-2",
"title": "Borders",
"description": "Borders create visual boundaries around elements. The <kbd>border</kbd> shorthand takes three values: width, style, and color.<br><br>Common styles: <kbd>solid</kbd>, <kbd>dashed</kbd>, <kbd>dotted</kbd>, <kbd>none</kbd>",
"task": "Add a subtle left accent to the card with <kbd>border-left: 4px solid steelblue</kbd>.",
"task": "This card could use a colored accent line along its left edge.",
"previewHTML": "<article class=\"card\"><h3>Sarah Chen</h3><p>Frontend Developer</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); padding: 1rem; } .card h3 { margin: 0 0 4px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -43,7 +43,7 @@
{
"type": "regex",
"value": "border-left:\\s*4px\\s+solid\\s+steelblue",
"message": "Set <kbd>border-left: 4px solid steelblue</kbd>",
"message": "Use the shorthand that sets a border on just one side",
"options": { "caseSensitive": false }
}
]
@@ -52,7 +52,7 @@
"id": "box-model-3",
"title": "Margins",
"description": "Margins create space <em>outside</em> the element, separating it from neighbors. While padding pushes content inward, margins push other elements away.",
"task": "Add space between these two profile cards with <kbd>margin-bottom: 1rem</kbd> on <kbd>.card</kbd>.",
"task": "These two profile cards are touching each other. Add some space below each card to separate them.",
"previewHTML": "<article class=\"card\"><h3>Sarah Chen</h3><p>Frontend Developer</p></article><article class=\"card\"><h3>Alex Rivera</h3><p>UX Designer</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); padding: 1rem; border-left: 4px solid steelblue; } .card h3 { margin: 0 0 4px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -65,7 +65,7 @@
{
"type": "property_value",
"value": { "property": "margin-bottom", "expected": "1rem" },
"message": "Set <kbd>margin-bottom: 1rem</kbd>"
"message": "Which property pushes neighboring elements away from the bottom?"
}
]
},
@@ -73,7 +73,7 @@
"id": "box-model-4",
"title": "Box Sizing",
"description": "By default, <kbd>width</kbd> only sets the content width. Padding and borders add to the total. This causes layout headaches.<br><br><kbd>box-sizing: border-box</kbd> includes padding and border in the width, making sizing predictable. Most developers apply this to all elements.",
"task": "Both cards have <kbd>width: 200px</kbd>. The left uses default sizing (content-box), making it wider than expected. Fix the right card with <kbd>box-sizing: border-box</kbd>.",
"task": "Both cards are set to the same width, but the left one overflows because padding and border are added on top. Fix the right card so its size includes padding and border.",
"previewHTML": "<div class=\"demo\"><article class=\"card\">Content-box</article><article class=\"card fix\">Border-box</article></div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .demo { display: flex; gap: 1rem; } .card { width: 200px; padding: 1rem; border: 4px solid steelblue; background: white; border-radius: 8px; }",
"sandboxCSS": "",
@@ -86,7 +86,7 @@
{
"type": "property_value",
"value": { "property": "box-sizing", "expected": "border-box" },
"message": "Set <kbd>box-sizing: border-box</kbd>"
"message": "Which sizing mode includes padding and border in the element's width?"
}
]
},
@@ -94,7 +94,7 @@
"id": "box-model-5",
"title": "Padding Shorthand",
"description": "Padding accepts 1-4 values:<br>• 1 value: all sides<br>• 2 values: vertical | horizontal<br>• 4 values: top | right | bottom | left",
"task": "This button needs more horizontal space than vertical. Set <kbd>padding: 8px 1rem</kbd> (8px top/bottom, 1rem left/right).",
"task": "This button feels too tight. Give it more space on the sides than on top and bottom, using the two-value shorthand.",
"previewHTML": "<button class=\"btn\">Follow</button>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .btn { background: steelblue; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; }",
"sandboxCSS": "",
@@ -107,7 +107,7 @@
{
"type": "regex",
"value": "padding:\\s*8px\\s+1rem",
"message": "Set <kbd>padding: 8px 1rem</kbd>",
"message": "Use the two-value shorthand: vertical first, then horizontal",
"options": { "caseSensitive": false }
}
]
@@ -116,7 +116,7 @@
"id": "box-model-6",
"title": "Margin Shorthand",
"description": "Margin uses the same shorthand pattern as padding. A common pattern is centering block elements horizontally with <kbd>margin: 0 auto</kbd>.",
"task": "Center this card horizontally. Set <kbd>margin: 0 auto</kbd> to auto-calculate equal left/right margins.",
"task": "This card is stuck to the left. Center it horizontally using the margin shorthand with auto side margins.",
"previewHTML": "<article class=\"card\"><h3>Sarah Chen</h3><p>Frontend Developer</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { width: 250px; background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); padding: 1rem; border-left: 4px solid steelblue; } .card h3 { margin: 0 0 4px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -129,7 +129,7 @@
{
"type": "regex",
"value": "margin:\\s*0\\s+auto",
"message": "Set <kbd>margin: 0 auto</kbd>",
"message": "Use the shorthand that auto-calculates equal horizontal margins",
"options": { "caseSensitive": false }
}
]
@@ -138,7 +138,7 @@
"id": "box-model-7",
"title": "Border Radius",
"description": "While not part of the classic box model, <kbd>border-radius</kbd> rounds the corners of an element's border box. Use <kbd>50%</kbd> on a square element to create a circle.",
"task": "Make the avatar image circular with <kbd>border-radius: 50%</kbd>.",
"task": "The square avatar image should appear as a perfect circle.",
"previewHTML": "<article class=\"card\"><img class=\"avatar\" src=\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ccircle cx='50' cy='35' r='25' fill='%23666'/%3E%3Ccircle cx='50' cy='90' r='40' fill='%23666'/%3E%3C/svg%3E\" alt=\"Avatar\"><h3>Sarah Chen</h3><p>Frontend Developer</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); padding: 1rem; text-align: center; } .avatar { width: 80px; height: 80px; background: #ddd; margin-bottom: 8px; } .card h3 { margin: 0 0 4px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -151,7 +151,7 @@
{
"type": "property_value",
"value": { "property": "border-radius", "expected": "50%" },
"message": "Set <kbd>border-radius: 50%</kbd>"
"message": "Which property rounds corners? Think about what percentage makes a circle"
}
]
},
@@ -159,7 +159,7 @@
"id": "box-model-8",
"title": "Complete Card",
"description": "Let's combine everything. This notification card needs styling to look polished.",
"task": "Style the notification: add <kbd>padding: 1rem</kbd>, <kbd>border-left: 4px solid coral</kbd>, and <kbd>border-radius: 4px</kbd>.",
"task": "This notification needs three things: inner spacing so text isn't cramped, a colored accent on the left edge, and slightly rounded corners.",
"previewHTML": "<div class=\"alert\"><strong>New message</strong><p>You have 3 unread notifications</p></div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .alert { background: seashell; } .alert strong { color: coral; } .alert p { margin: 4px 0 0; color: #666; }",
"sandboxCSS": "",
@@ -172,18 +172,18 @@
{
"type": "property_value",
"value": { "property": "padding", "expected": "1rem" },
"message": "Set <kbd>padding: 1rem</kbd>"
"message": "Add inner spacing to the notification"
},
{
"type": "regex",
"value": "border-left:\\s*4px\\s+solid\\s+coral",
"message": "Set <kbd>border-left: 4px solid coral</kbd>",
"message": "Add a colored accent on the left edge",
"options": { "caseSensitive": false }
},
{
"type": "property_value",
"value": { "property": "border-radius", "expected": "4px" },
"message": "Set <kbd>border-radius: 4px</kbd>"
"message": "Soften the corners of the notification"
}
]
}

View File

@@ -9,7 +9,7 @@
"id": "colors-1",
"title": "Background Color",
"description": "Color is one of the most powerful tools in web design. It creates visual hierarchy, conveys meaning, and establishes brand identity. CSS provides multiple ways to specify colors.<br><br><strong>CSS named colors:</strong> CSS includes 147 named colors like <kbd>steelblue</kbd>, <kbd>coral</kbd>, <kbd>gold</kbd>, and <kbd>tomato</kbd>. These are easy to remember and read.<br><br><strong>The background-color property:</strong> Sets the fill color behind an element's content and padding areas.<br><br><pre>.card {\n background-color: lightblue;\n}</pre>",
"task": "This notification card needs a subtle background. Add <kbd>background-color: seashell</kbd>.",
"task": "This notification card looks bare. Give it a soft, warm background color.",
"previewHTML": "<div class=\"alert\"><strong>New message</strong><p>You have 3 unread notifications</p></div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .alert { padding: 1rem; border-left: 4px solid coral; border-radius: 4px; } .alert strong { display: block; margin-bottom: 4px; } .alert p { margin: 0; color: #666; font-size: 0.9rem; }",
"sandboxCSS": "",
@@ -20,9 +20,10 @@
"previewContainer": "preview-area",
"validations": [
{
"type": "property_value",
"value": { "property": "background-color", "expected": "seashell" },
"message": "Set <kbd>background-color: seashell</kbd>"
"type": "regex",
"value": "background-color:\\s*(seashell|linen|mistyrose|lavenderblush|cornsilk|oldlace|papayawhip|antiquewhite|bisque|peachpuff)",
"message": "Which property fills the area behind the content? Try a warm, soft color name",
"options": { "caseSensitive": false }
}
]
},
@@ -30,7 +31,7 @@
"id": "colors-2",
"title": "Text Color",
"description": "The <kbd>color</kbd> property sets the color of text content. Good contrast between text and background is essential for readability and accessibility.",
"task": "Make the alert title stand out. Add <kbd>color: coral</kbd>.",
"task": "The alert title blends in with the body text. Make it pop with a warm accent color.",
"previewHTML": "<div class=\"alert\"><strong class=\"title\">Warning</strong><p>Your session will expire in 5 minutes</p></div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .alert { padding: 1rem; background-color: seashell; border-left: 4px solid coral; border-radius: 4px; } .alert .title { display: block; margin-bottom: 4px; } .alert p { margin: 0; color: #666; font-size: 0.9rem; }",
"sandboxCSS": "",
@@ -41,9 +42,10 @@
"previewContainer": "preview-area",
"validations": [
{
"type": "property_value",
"value": { "property": "color", "expected": "coral" },
"message": "Set <kbd>color: coral</kbd>"
"type": "regex",
"value": "color:\\s*(coral|tomato|orangered|indianred|salmon|darksalmon)",
"message": "Which property changes the text color? Try a warm, vibrant color name",
"options": { "caseSensitive": false }
}
]
},
@@ -51,7 +53,7 @@
"id": "colors-3",
"title": "Border Color",
"description": "Borders can have their own color using <kbd>border-color</kbd>. This is useful when you want to change just the color without redefining the entire border.",
"task": "This card needs an accent border. Add <kbd>border-color: coral</kbd>.",
"task": "The card border is dull gray. Give it a warm accent color.",
"previewHTML": "<article class=\"card\"><h3>Premium Plan</h3><p>Unlimited access to all features</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { padding: 1rem; background: white; border: 4px solid #ddd; border-radius: 8px; } .card h3 { margin: 0 0 8px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -62,9 +64,10 @@
"previewContainer": "preview-area",
"validations": [
{
"type": "property_value",
"value": { "property": "border-color", "expected": "coral" },
"message": "Set <kbd>border-color: coral</kbd>"
"type": "regex",
"value": "border-color:\\s*(coral|tomato|orangered|indianred|salmon|darksalmon|crimson)",
"message": "Which property changes just the border's color? Try a warm, vibrant name",
"options": { "caseSensitive": false }
}
]
},
@@ -72,7 +75,7 @@
"id": "colors-4",
"title": "Hex Colors",
"description": "Beyond named colors, CSS supports hex codes (<kbd>#ff6347</kbd>), RGB (<kbd>rgb(255, 99, 71)</kbd>), and HSL (<kbd>hsl(9, 100%, 64%)</kbd>). Hex codes are the most common format in professional projects.",
"task": "Set the badge background to gold using its hex code. Add <kbd>background-color: #ffd700</kbd>.",
"task": "This badge needs a golden background. Use a hex color code to set it.",
"previewHTML": "<span class=\"badge\">NEW</span>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .badge { display: inline-block; padding: 4px 12px; border-radius: 999px; font-size: 0.75rem; font-weight: bold; text-transform: uppercase; color: #333; }",
"sandboxCSS": "",
@@ -83,9 +86,10 @@
"previewContainer": "preview-area",
"validations": [
{
"type": "property_value",
"value": { "property": "background-color", "expected": "#ffd700" },
"message": "Set <kbd>background-color: #ffd700</kbd>"
"type": "regex",
"value": "background-color:\\s*(#ffd700|#ffcc00|#ffc107|#f0c000|gold)",
"message": "Use a hex code for background-color — something in the gold/yellow family",
"options": { "caseSensitive": false }
}
]
}

View File

@@ -9,7 +9,7 @@
"id": "filters-1",
"title": "Blur Filter",
"description": "The <kbd>filter</kbd> property applies visual effects to elements. The <kbd>blur()</kbd> function creates a Gaussian blur effect.<br><br><pre>filter: blur(4px);</pre><br>Higher values create more blur. This is great for backgrounds or creating depth.",
"task": "Blur the background image using <kbd>filter: blur(4px)</kbd>.",
"task": "Blur the background image to create a frosted-glass effect. Use a blur radius between 2px and 8px.",
"previewHTML": "<div class=\"bg\"></div><div class=\"content\"><h2>Welcome</h2></div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; margin: 0; height: 200px; position: relative; overflow: hidden; } .bg { position: absolute; inset: 0; background: linear-gradient(45deg, coral, gold, steelblue); } .content { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; height: 100%; } .content h2 { color: white; text-shadow: 0 2px 8px rgba(0,0,0,0.3); margin: 0; }",
"sandboxCSS": "",
@@ -20,9 +20,10 @@
"previewContainer": "preview-area",
"validations": [
{
"type": "property_value",
"value": { "property": "filter", "expected": "blur(4px)" },
"message": "Set <kbd>filter: blur(4px)</kbd>"
"type": "regex",
"value": "filter:\\s*blur\\((2|3|4|5|6|7|8)px\\)",
"message": "Use the <kbd>filter</kbd> property with the <kbd>blur()</kbd> function. Try a value between 2px and 8px",
"options": { "caseSensitive": false }
}
]
},
@@ -30,7 +31,7 @@
"id": "filters-2",
"title": "Grayscale Filter",
"description": "The <kbd>grayscale()</kbd> function removes color from an element. Use values from <kbd>0%</kbd> (full color) to <kbd>100%</kbd> (fully grayscale).<br><br><pre>filter: grayscale(100%);</pre><br>Great for hover effects or disabled states.",
"task": "Make the image grayscale with <kbd>filter: grayscale(100%)</kbd>.",
"task": "Remove all color from the image to create a black-and-white effect.",
"previewHTML": "<div class=\"photo\"></div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .photo { width: 200px; height: 150px; background: linear-gradient(135deg, coral 0%, gold 50%, steelblue 100%); border-radius: 8px; }",
"sandboxCSS": "",
@@ -41,14 +42,10 @@
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "grayscale",
"message": "Use <kbd>grayscale()</kbd> filter"
},
{
"type": "contains",
"value": "100%",
"message": "Set to <kbd>100%</kbd> for full grayscale"
"type": "regex",
"value": "filter:\\s*grayscale\\(100%\\)",
"message": "Which filter function removes color from an element? Set it to full strength",
"options": { "caseSensitive": false }
}
]
},
@@ -56,7 +53,7 @@
"id": "filters-3",
"title": "Brightness Filter",
"description": "The <kbd>brightness()</kbd> function adjusts how bright an element appears. Values below <kbd>100%</kbd> darken, above <kbd>100%</kbd> brighten.<br><br><pre>filter: brightness(150%);</pre>",
"task": "Brighten the card with <kbd>filter: brightness(120%)</kbd>.",
"task": "Make the card appear brighter and more vivid. Use a brightness value between 110% and 150%.",
"previewHTML": "<div class=\"card\"><span>Featured</span></div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #1a1a2e; } .card { padding: 2rem; background: linear-gradient(135deg, #4a4a6a, #2a2a4a); border-radius: 12px; text-align: center; } .card span { color: gold; font-weight: 600; text-transform: uppercase; letter-spacing: 2px; }",
"sandboxCSS": "",
@@ -67,14 +64,10 @@
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "brightness",
"message": "Use <kbd>brightness()</kbd> filter"
},
{
"type": "contains",
"value": "120%",
"message": "Set to <kbd>120%</kbd>"
"type": "regex",
"value": "filter:\\s*brightness\\(1[1-5]0%\\)",
"message": "Use the <kbd>filter</kbd> property with the <kbd>brightness()</kbd> function. Values above 100% make things brighter",
"options": { "caseSensitive": false }
}
]
},
@@ -82,7 +75,7 @@
"id": "filters-4",
"title": "Drop Shadow",
"description": "The <kbd>drop-shadow()</kbd> filter creates a shadow that follows the shape of the element, including transparency. Unlike <kbd>box-shadow</kbd>, it works on images with transparent backgrounds.<br><br><pre>filter: drop-shadow(2px 4px 6px black);</pre>",
"task": "Add a drop shadow with <kbd>filter: drop-shadow(4px 4px 8px gray)</kbd>.",
"task": "Add a soft shadow behind the star to give it depth. Use the drop-shadow filter with offset, blur, and a color.",
"previewHTML": "<div class=\"icon\">★</div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 2rem; display: flex; justify-content: center; } .icon { font-size: 4rem; color: gold; }",
"sandboxCSS": "",
@@ -93,14 +86,10 @@
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "drop-shadow",
"message": "Use <kbd>drop-shadow()</kbd> filter"
},
{
"type": "contains",
"value": "4px 4px 8px",
"message": "Set shadow offset and blur"
"type": "regex",
"value": "filter:\\s*drop-shadow\\(\\d+px\\s+\\d+px\\s+\\d+px\\s+\\w+\\)",
"message": "Use the <kbd>filter</kbd> property with <kbd>drop-shadow()</kbd>. It needs horizontal offset, vertical offset, blur radius, and a color",
"options": { "caseSensitive": false }
}
]
}

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,139 @@
{
"$schema": "../schemas/code-crispies-module-schema.json",
"id": "js-variables",
"title": "JS Variables",
"description": "Learn to declare variables with let and const, and work with basic data types in JavaScript.",
"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 the default choice for most values in modern JavaScript.",
"task": "Declare a constant named <kbd>name</kbd> with the value <kbd>\"Alice\"</kbd>",
"previewHTML": "<p id=\"out\">Waiting...</p>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }",
"sandboxCSS": "",
"initialCode": "",
"codePrefix": "",
"codeSuffix": "\ndocument.getElementById('out').textContent = name;",
"solution": "const name = \"Alice\";",
"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": "Declare a constant called <kbd>name</kbd>"
},
{
"type": "regex",
"value": "\"Alice\"|'Alice'|`Alice`",
"message": "Set the value to <kbd>\"Alice\"</kbd>"
}
]
},
{
"id": "js-let",
"title": "Let Variables",
"description": "Use <kbd>let</kbd> to declare variables that you plan to reassign later. Unlike <kbd>const</kbd>, a <kbd>let</kbd> variable can change its value.",
"task": "Declare a variable <kbd>count</kbd> with <kbd>let</kbd> set to <kbd>0</kbd>, then reassign it to <kbd>5</kbd>",
"previewHTML": "<p id=\"out\">Waiting...</p>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }",
"sandboxCSS": "",
"initialCode": "",
"codePrefix": "",
"codeSuffix": "\ndocument.getElementById('out').textContent = count;",
"solution": "let count = 0;\ncount = 5;",
"previewContainer": "preview-area",
"validations": [
{
"type": "regex",
"value": "let\\s+count\\s*=\\s*0",
"message": "Start with <kbd>let count = 0;</kbd>"
},
{
"type": "regex",
"value": "count\\s*=\\s*5",
"message": "Reassign count to <kbd>5</kbd>"
}
]
},
{
"id": "js-string",
"title": "Template Literals",
"description": "Template literals use backticks <kbd>`</kbd> and <kbd>${}</kbd> to embed expressions inside strings. This makes building dynamic text much easier than string concatenation.",
"task": "Create a constant <kbd>msg</kbd> using a template literal: <kbd>`Hello, ${name}!`</kbd>",
"previewHTML": "<p id=\"out\">Waiting...</p>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }",
"sandboxCSS": "",
"initialCode": "",
"codePrefix": "const name = \"World\";\n",
"codeSuffix": "\ndocument.getElementById('out').textContent = msg;",
"solution": "const msg = `Hello, ${name}!`;",
"previewContainer": "preview-area",
"validations": [
{
"type": "regex",
"value": "const\\s+msg\\s*=",
"message": "Declare a constant called <kbd>msg</kbd>"
},
{
"type": "contains",
"value": "${name}",
"message": "Use <kbd>${name}</kbd> inside backticks to embed the variable"
},
{
"type": "regex",
"value": "`.*\\$\\{name\\}.*`",
"message": "Wrap the whole string in backticks <kbd>`</kbd>"
}
]
},
{
"id": "js-array",
"title": "Arrays",
"description": "Arrays store ordered lists of values in square brackets. Access items by index (starting at 0) and use <kbd>.length</kbd> to get the count.",
"task": "Create a constant <kbd>colors</kbd> with an array: <kbd>[\"red\", \"green\", \"blue\"]</kbd>",
"previewHTML": "<p id=\"out\">Waiting...</p>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }",
"sandboxCSS": "",
"initialCode": "",
"codePrefix": "",
"codeSuffix": "\ndocument.getElementById('out').textContent = colors.join(', ');",
"solution": "const colors = [\"red\", \"green\", \"blue\"];",
"previewContainer": "preview-area",
"validations": [
{
"type": "regex",
"value": "const\\s+colors\\s*=",
"message": "Declare a constant called <kbd>colors</kbd>"
},
{
"type": "contains",
"value": "[",
"message": "Use square brackets <kbd>[</kbd> to create an array"
},
{
"type": "regex",
"value": "(\"red\"|'red'|`red`)",
"message": "Include <kbd>\"red\"</kbd> in the array"
},
{
"type": "regex",
"value": "(\"green\"|'green'|`green`)",
"message": "Include <kbd>\"green\"</kbd> in the array"
},
{
"type": "regex",
"value": "(\"blue\"|'blue'|`blue`)",
"message": "Include <kbd>\"blue\"</kbd> in the array"
}
]
}
]
}

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

@@ -0,0 +1,139 @@
{
"$schema": "../schemas/code-crispies-module-schema.json",
"id": "js-dom",
"title": "JS DOM",
"description": "Learn to select and modify HTML elements using JavaScript DOM methods like querySelector and textContent.",
"mode": "javascript",
"difficulty": "beginner",
"lessons": [
{
"id": "js-query",
"title": "querySelector",
"description": "Use <kbd>document.querySelector()</kbd> to find the first element matching a CSS selector. It returns a single element you can then modify.",
"task": "Select the <kbd>h1</kbd> element and store it in a constant called <kbd>title</kbd>",
"previewHTML": "<h1>Hello</h1><p id=\"out\">Waiting...</p>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }",
"sandboxCSS": "",
"initialCode": "",
"codePrefix": "",
"codeSuffix": "\ndocument.getElementById('out').textContent = title.tagName;",
"solution": "const title = document.querySelector('h1');",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "querySelector",
"message": "Use <kbd>document.querySelector()</kbd> to select an element"
},
{
"type": "regex",
"value": "querySelector\\(['\"`]h1['\"`]\\)",
"message": "Pass <kbd>'h1'</kbd> as the selector"
},
{
"type": "regex",
"value": "const\\s+title\\s*=",
"message": "Store the result in a constant called <kbd>title</kbd>"
}
]
},
{
"id": "js-text",
"title": "textContent",
"description": "The <kbd>textContent</kbd> property lets you read or change the text inside an element. Setting it replaces all existing text.",
"task": "Select the <kbd>.msg</kbd> element and set its <kbd>textContent</kbd> to <kbd>\"Done!\"</kbd>",
"previewHTML": "<p class=\"msg\">Waiting...</p>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }",
"sandboxCSS": "",
"initialCode": "",
"codePrefix": "",
"codeSuffix": "",
"solution": "document.querySelector('.msg').textContent = \"Done!\";",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "querySelector",
"message": "Use <kbd>querySelector</kbd> to find the element"
},
{
"type": "contains",
"value": "textContent",
"message": "Use the <kbd>textContent</kbd> property to change the text"
},
{
"type": "regex",
"value": "(\"Done!\"|'Done!'|`Done!`)",
"message": "Set the text to <kbd>\"Done!\"</kbd>"
}
]
},
{
"id": "js-style",
"title": "Inline Styles",
"description": "Access the <kbd>style</kbd> property to set inline CSS on an element. CSS properties with dashes become camelCase: <kbd>background-color</kbd> becomes <kbd>backgroundColor</kbd>.",
"task": "Select the <kbd>.box</kbd> element and set its <kbd>style.color</kbd> to <kbd>\"coral\"</kbd>",
"previewHTML": "<p class=\"box\">Style me!</p>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .box { font-size: 1.5rem; font-weight: bold; }",
"sandboxCSS": "",
"initialCode": "",
"codePrefix": "",
"codeSuffix": "",
"solution": "document.querySelector('.box').style.color = \"coral\";",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "querySelector",
"message": "Use <kbd>querySelector</kbd> to find the element"
},
{
"type": "contains",
"value": ".style.",
"message": "Use the <kbd>.style</kbd> property to set CSS"
},
{
"type": "regex",
"value": "style\\.color\\s*=",
"message": "Set <kbd>style.color</kbd> on the element"
},
{
"type": "regex",
"value": "(\"coral\"|'coral'|`coral`)",
"message": "Set the color to <kbd>\"coral\"</kbd>"
}
]
},
{
"id": "js-classlist",
"title": "classList",
"description": "The <kbd>classList</kbd> property provides methods to add, remove, or toggle CSS classes on an element without touching other classes.",
"task": "Select the <kbd>.card</kbd> element and add the class <kbd>\"active\"</kbd> using <kbd>classList.add()</kbd>",
"previewHTML": "<div class=\"card\">Toggle me</div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .card { padding: 1rem; border: 2px solid gray; border-radius: 8px; } .active { border-color: coral; background: #fff0ee; }",
"sandboxCSS": "",
"initialCode": "",
"codePrefix": "",
"codeSuffix": "",
"solution": "document.querySelector('.card').classList.add(\"active\");",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "classList",
"message": "Use the <kbd>classList</kbd> property"
},
{
"type": "regex",
"value": "classList\\.add\\(",
"message": "Call <kbd>classList.add()</kbd> to add a class"
},
{
"type": "regex",
"value": "(\"active\"|'active'|`active`)",
"message": "Add the class <kbd>\"active\"</kbd>"
}
]
}
]
}

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

@@ -0,0 +1,118 @@
{
"$schema": "../schemas/code-crispies-module-schema.json",
"id": "js-events",
"title": "JS Events",
"description": "Learn to respond to user interactions with addEventListener for clicks, input changes, and keyboard events.",
"mode": "javascript",
"difficulty": "beginner",
"lessons": [
{
"id": "js-click",
"title": "Click Events",
"description": "Use <kbd>addEventListener('click', ...)</kbd> to run code when a user clicks an element. The first argument is the event name, the second is a callback function.",
"task": "Add a click listener to the <kbd>.btn</kbd> element that sets the <kbd>.msg</kbd> text to <kbd>\"Clicked!\"</kbd>",
"previewHTML": "<button class=\"btn\">Click me</button><p class=\"msg\">Waiting...</p>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .btn { padding: 0.5rem 1rem; border: none; background: steelblue; color: white; border-radius: 4px; cursor: pointer; }",
"sandboxCSS": "",
"initialCode": "",
"codePrefix": "const btn = document.querySelector('.btn');\nconst msg = document.querySelector('.msg');\n\n",
"codeSuffix": "",
"solution": "btn.addEventListener('click', () => {\n msg.textContent = \"Clicked!\";\n});",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "addEventListener",
"message": "Use <kbd>addEventListener</kbd> to listen for events"
},
{
"type": "regex",
"value": "addEventListener\\(['\"`]click['\"`]",
"message": "Listen for the <kbd>'click'</kbd> event"
},
{
"type": "contains",
"value": "textContent",
"message": "Use <kbd>textContent</kbd> to update the text"
},
{
"type": "regex",
"value": "(\"Clicked!\"|'Clicked!'|`Clicked!`)",
"message": "Set the text to <kbd>\"Clicked!\"</kbd>"
}
]
},
{
"id": "js-toggle",
"title": "Toggle Classes",
"description": "Combine events with <kbd>classList.toggle()</kbd> to switch a class on and off. Each click adds the class if missing, or removes it if present.",
"task": "Add a click listener to <kbd>.btn</kbd> that toggles the class <kbd>\"on\"</kbd> on <kbd>.lamp</kbd>",
"previewHTML": "<button class=\"btn\">Toggle</button><div class=\"lamp\">💡</div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; text-align: center; } .btn { padding: 0.5rem 1rem; border: none; background: steelblue; color: white; border-radius: 4px; cursor: pointer; } .lamp { font-size: 3rem; margin-top: 1rem; opacity: 0.3; transition: opacity 0.3s; } .lamp.on { opacity: 1; }",
"sandboxCSS": "",
"initialCode": "",
"codePrefix": "const btn = document.querySelector('.btn');\nconst lamp = document.querySelector('.lamp');\n\n",
"codeSuffix": "",
"solution": "btn.addEventListener('click', () => {\n lamp.classList.toggle('on');\n});",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "addEventListener",
"message": "Use <kbd>addEventListener</kbd> to listen for events"
},
{
"type": "regex",
"value": "addEventListener\\(['\"`]click['\"`]",
"message": "Listen for the <kbd>'click'</kbd> event"
},
{
"type": "regex",
"value": "classList\\.toggle\\(",
"message": "Use <kbd>classList.toggle()</kbd> to switch the class"
},
{
"type": "regex",
"value": "(\"on\"|'on'|`on`)",
"message": "Toggle the class <kbd>\"on\"</kbd>"
}
]
},
{
"id": "js-input",
"title": "Input Events",
"description": "The <kbd>input</kbd> event fires every time the value of an input field changes. Use <kbd>event.target.value</kbd> to read the current value.",
"task": "Add an input listener to <kbd>.field</kbd> that sets <kbd>.out</kbd> text to the input's value",
"previewHTML": "<input class=\"field\" placeholder=\"Type here...\"><p class=\"out\">Echo: </p>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .field { padding: 0.5rem; border: 2px solid #ccc; border-radius: 4px; font-size: 1rem; width: 100%; box-sizing: border-box; }",
"sandboxCSS": "",
"initialCode": "",
"codePrefix": "const field = document.querySelector('.field');\nconst out = document.querySelector('.out');\n\n",
"codeSuffix": "",
"solution": "field.addEventListener('input', (event) => {\n out.textContent = event.target.value;\n});",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "addEventListener",
"message": "Use <kbd>addEventListener</kbd> to listen for events"
},
{
"type": "regex",
"value": "addEventListener\\(['\"`]input['\"`]",
"message": "Listen for the <kbd>'input'</kbd> event"
},
{
"type": "contains",
"value": "textContent",
"message": "Use <kbd>textContent</kbd> to update the output"
},
{
"type": "regex",
"value": "(event|e|evt)\\.target\\.value",
"message": "Read the input value with <kbd>event.target.value</kbd>"
}
]
}
]
}

View File

@@ -9,7 +9,7 @@
"id": "box-model-1",
"title": "Padding",
"description": "كل عنصر في CSS هو صندوق بأربع طبقات: المحتوى، الحشو (padding)، الحدود، والهامش. <strong>Padding</strong> يخلق مساحة تنفس بين محتواك وحافة الصندوق.<br><br>بدون padding، يضغط النص بشكل محرج على الحدود. Padding يجعل المحتوى قابلاً للقراءة ومتوازناً بصرياً.<br><br><pre>.card {\n padding: 1rem;\n}</pre>",
"task": "بطاقة الملف الشخصي هذه تبدو ضيقة. أضف <kbd>padding: 1rem</kbd> ليكون للنص مجال للتنفس.",
"task": "النص داخل بطاقة الملف الشخصي ملتصق بالحواف. امنحه بعض المساحة الداخلية للتنفس.",
"previewHTML": "<article class=\"card\"><h3>Sarah Chen</h3><p>Frontend Developer</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } .card h3 { margin: 0 0 4px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -22,7 +22,7 @@
{
"type": "property_value",
"value": { "property": "padding", "expected": "1rem" },
"message": "اضبط <kbd>padding: 1rem</kbd>"
"message": "أي خاصية تضيف مساحة بين المحتوى وحافة العنصر؟"
}
]
},
@@ -30,7 +30,7 @@
"id": "box-model-2",
"title": "Borders",
"description": "الحدود تنشئ حدوداً مرئية حول العناصر. اختصار <kbd>border</kbd> يقبل ثلاث قيم: العرض، النمط، واللون.<br><br>الأنماط الشائعة: <kbd>solid</kbd>، <kbd>dashed</kbd>، <kbd>dotted</kbd>، <kbd>none</kbd>",
"task": "أضف لمسة يسارية خفيفة للبطاقة باستخدام <kbd>border-left: 4px solid steelblue</kbd>.",
"task": "هذه البطاقة تحتاج خطاً ملوناً كلمسة على حافتها اليسرى.",
"previewHTML": "<article class=\"card\"><h3>Sarah Chen</h3><p>Frontend Developer</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); padding: 1rem; } .card h3 { margin: 0 0 4px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -43,7 +43,7 @@
{
"type": "regex",
"value": "border-left:\\s*4px\\s+solid\\s+steelblue",
"message": "اضبط <kbd>border-left: 4px solid steelblue</kbd>",
"message": "استخدم الاختصار الذي يحدد حداً على جانب واحد فقط",
"options": { "caseSensitive": false }
}
]
@@ -52,7 +52,7 @@
"id": "box-model-3",
"title": "Margins",
"description": "الهوامش تنشئ مساحة <em>خارج</em> العنصر، تفصله عن جيرانه. بينما يدفع padding المحتوى للداخل، الهوامش تدفع العناصر الأخرى بعيداً.",
"task": "أضف مساحة بين بطاقتي الملف الشخصي هاتين باستخدام <kbd>margin-bottom: 1rem</kbd> على <kbd>.card</kbd>.",
"task": "بطاقتا الملف الشخصي ملتصقتان ببعضهما. أضف مساحة أسفل كل بطاقة للفصل بينهما.",
"previewHTML": "<article class=\"card\"><h3>Sarah Chen</h3><p>Frontend Developer</p></article><article class=\"card\"><h3>Alex Rivera</h3><p>UX Designer</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); padding: 1rem; border-left: 4px solid steelblue; } .card h3 { margin: 0 0 4px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -65,7 +65,7 @@
{
"type": "property_value",
"value": { "property": "margin-bottom", "expected": "1rem" },
"message": "اضبط <kbd>margin-bottom: 1rem</kbd>"
"message": "أي خاصية تدفع العناصر المجاورة بعيداً من الأسفل؟"
}
]
},
@@ -73,7 +73,7 @@
"id": "box-model-4",
"title": "Box Sizing",
"description": "افتراضياً، <kbd>width</kbd> يحدد فقط عرض المحتوى. Padding والحدود تُضاف للمجموع. هذا يسبب مشاكل في التخطيط.<br><br><kbd>box-sizing: border-box</kbd> يشمل padding والحدود في العرض، مما يجعل التحجيم متوقعاً. معظم المطورين يطبقون هذا على جميع العناصر.",
"task": "كلا البطاقتين لهما <kbd>width: 200px</kbd>. اليسرى تستخدم التحجيم الافتراضي (content-box)، مما يجعلها أعرض من المتوقع. أصلح البطاقة اليمنى باستخدام <kbd>box-sizing: border-box</kbd>.",
"task": "كلا البطاقتين بنفس العرض، لكن اليسرى تتجاوز لأن الحشو والحدود تُضاف فوق العرض. أصلح البطاقة اليمنى لتشمل الحشو والحدود في حجمها.",
"previewHTML": "<div class=\"demo\"><article class=\"card\">Content-box</article><article class=\"card fix\">Border-box</article></div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .demo { display: flex; gap: 1rem; } .card { width: 200px; padding: 1rem; border: 4px solid steelblue; background: white; border-radius: 8px; }",
"sandboxCSS": "",
@@ -86,7 +86,7 @@
{
"type": "property_value",
"value": { "property": "box-sizing", "expected": "border-box" },
"message": "اضبط <kbd>box-sizing: border-box</kbd>"
"message": "أي وضع تحجيم يشمل padding والحدود في عرض العنصر؟"
}
]
},
@@ -94,7 +94,7 @@
"id": "box-model-5",
"title": "Padding Shorthand",
"description": "Padding يقبل 1-4 قيم:<br>• قيمة واحدة: جميع الجوانب<br>• قيمتان: عمودي | أفقي<br>• 4 قيم: أعلى | يمين | أسفل | يسار",
"task": "هذا الزر يحتاج مساحة أفقية أكثر من العمودية. اضبط <kbd>padding: 8px 1rem</kbd> (8px أعلى/أسفل، 1rem يسار/يمين).",
"task": "هذا الزر ضيق جداً. امنحه مساحة على الجوانب أكثر من الأعلى والأسفل، باستخدام اختصار القيمتين.",
"previewHTML": "<button class=\"btn\">Follow</button>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .btn { background: steelblue; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; }",
"sandboxCSS": "",
@@ -107,7 +107,7 @@
{
"type": "regex",
"value": "padding:\\s*8px\\s+1rem",
"message": "اضبط <kbd>padding: 8px 1rem</kbd>",
"message": "استخدم اختصار القيمتين: العمودي أولاً، ثم الأفقي",
"options": { "caseSensitive": false }
}
]
@@ -116,7 +116,7 @@
"id": "box-model-6",
"title": "Margin Shorthand",
"description": "Margin يستخدم نفس نمط الاختصار مثل padding. نمط شائع هو توسيط عناصر الكتلة أفقياً باستخدام <kbd>margin: 0 auto</kbd>.",
"task": "وسّط هذه البطاقة أفقياً. اضبط <kbd>margin: 0 auto</kbd> لحساب هوامش يسار/يمين متساوية تلقائياً.",
"task": "هذه البطاقة ملتصقة باليسار. وسّطها أفقياً باستخدام اختصار الهوامش مع هوامش جانبية تلقائية.",
"previewHTML": "<article class=\"card\"><h3>Sarah Chen</h3><p>Frontend Developer</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { width: 250px; background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); padding: 1rem; border-left: 4px solid steelblue; } .card h3 { margin: 0 0 4px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -129,7 +129,7 @@
{
"type": "regex",
"value": "margin:\\s*0\\s+auto",
"message": "اضبط <kbd>margin: 0 auto</kbd>",
"message": "استخدم الاختصار الذي يحسب هوامش أفقية متساوية تلقائياً",
"options": { "caseSensitive": false }
}
]
@@ -138,7 +138,7 @@
"id": "box-model-7",
"title": "Border Radius",
"description": "على الرغم من أنه ليس جزءاً من نموذج الصندوق الكلاسيكي، <kbd>border-radius</kbd> يُدوّر زوايا صندوق حدود العنصر. استخدم <kbd>50%</kbd> على عنصر مربع لإنشاء دائرة.",
"task": "اجعل صورة الأفاتار دائرية باستخدام <kbd>border-radius: 50%</kbd>.",
"task": "صورة الأفاتار المربعة يجب أن تظهر كدائرة مثالية.",
"previewHTML": "<article class=\"card\"><img class=\"avatar\" src=\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ccircle cx='50' cy='35' r='25' fill='%23666'/%3E%3Ccircle cx='50' cy='90' r='40' fill='%23666'/%3E%3C/svg%3E\" alt=\"Avatar\"><h3>Sarah Chen</h3><p>Frontend Developer</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); padding: 1rem; text-align: center; } .avatar { width: 80px; height: 80px; background: #ddd; margin-bottom: 8px; } .card h3 { margin: 0 0 4px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -151,7 +151,7 @@
{
"type": "property_value",
"value": { "property": "border-radius", "expected": "50%" },
"message": "اضبط <kbd>border-radius: 50%</kbd>"
"message": "أي خاصية تدوّر الزوايا؟ فكر في النسبة المئوية التي تصنع دائرة"
}
]
},
@@ -159,7 +159,7 @@
"id": "box-model-8",
"title": "Complete Card",
"description": "لنجمع كل شيء معاً. بطاقة الإشعار هذه تحتاج تنسيقاً لتبدو احترافية.",
"task": "نسّق الإشعار: أضف <kbd>padding: 1rem</kbd>، <kbd>border-left: 4px solid coral</kbd>، و<kbd>border-radius: 4px</kbd>.",
"task": "هذا الإشعار يحتاج ثلاثة أشياء: مساحة داخلية حتى لا يكون النص مزدحماً، لمسة ملونة على الحافة اليسرى، وزوايا مستديرة قليلاً.",
"previewHTML": "<div class=\"alert\"><strong>New message</strong><p>You have 3 unread notifications</p></div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .alert { background: seashell; } .alert strong { color: coral; } .alert p { margin: 4px 0 0; color: #666; }",
"sandboxCSS": "",
@@ -172,18 +172,18 @@
{
"type": "property_value",
"value": { "property": "padding", "expected": "1rem" },
"message": "اضبط <kbd>padding: 1rem</kbd>"
"message": "أضف مساحة داخلية للإشعار"
},
{
"type": "regex",
"value": "border-left:\\s*4px\\s+solid\\s+coral",
"message": "اضبط <kbd>border-left: 4px solid coral</kbd>",
"message": "أضف لمسة ملونة على الحافة اليسرى",
"options": { "caseSensitive": false }
},
{
"type": "property_value",
"value": { "property": "border-radius", "expected": "4px" },
"message": "اضبط <kbd>border-radius: 4px</kbd>"
"message": "نعّم زوايا الإشعار"
}
]
}

View File

@@ -9,7 +9,7 @@
"id": "box-model-1",
"title": "Padding",
"description": "Jedes Element in CSS ist eine Box mit vier Schichten: Inhalt, Padding, Rahmen und Margin. <strong>Padding</strong> schafft Freiraum zwischen deinem Inhalt und dem Rand der Box.<br><br>Ohne Padding drückt sich Text unangenehm gegen Rahmen. Padding macht Inhalte lesbar und visuell ausgewogen.<br><br><pre>.card {\n padding: 1rem;\n}</pre>",
"task": "Diese Profilkarte sieht beengt aus. Füge <kbd>padding: 1rem</kbd> hinzu, damit der Text Platz zum Atmen hat.",
"task": "Der Text in dieser Profilkarte klebt direkt an den Rändern. Gib ihm etwas inneren Freiraum zum Atmen.",
"previewHTML": "<article class=\"card\"><h3>Sarah Chen</h3><p>Frontend Developer</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } .card h3 { margin: 0 0 4px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -22,7 +22,7 @@
{
"type": "property_value",
"value": { "property": "padding", "expected": "1rem" },
"message": "Setze <kbd>padding: 1rem</kbd>"
"message": "Welche Eigenschaft fügt Abstand zwischen Inhalt und Elementrand hinzu?"
}
]
},
@@ -30,7 +30,7 @@
"id": "box-model-2",
"title": "Borders",
"description": "Rahmen erstellen visuelle Grenzen um Elemente. Die <kbd>border</kbd>-Kurzschreibweise akzeptiert drei Werte: Breite, Stil und Farbe.<br><br>Häufige Stile: <kbd>solid</kbd>, <kbd>dashed</kbd>, <kbd>dotted</kbd>, <kbd>none</kbd>",
"task": "Füge der Karte einen dezenten linken Akzent hinzu mit <kbd>border-left: 4px solid steelblue</kbd>.",
"task": "Diese Karte könnte eine farbige Akzentlinie an der linken Seite gebrauchen.",
"previewHTML": "<article class=\"card\"><h3>Sarah Chen</h3><p>Frontend Developer</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); padding: 1rem; } .card h3 { margin: 0 0 4px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -43,7 +43,7 @@
{
"type": "regex",
"value": "border-left:\\s*4px\\s+solid\\s+steelblue",
"message": "Setze <kbd>border-left: 4px solid steelblue</kbd>",
"message": "Verwende die Kurzschreibweise, die einen Rahmen auf nur einer Seite setzt",
"options": { "caseSensitive": false }
}
]
@@ -52,7 +52,7 @@
"id": "box-model-3",
"title": "Margins",
"description": "Margins schaffen Abstand <em>außerhalb</em> des Elements und trennen es von Nachbarn. Während Padding den Inhalt nach innen drückt, drücken Margins andere Elemente weg.",
"task": "Füge Abstand zwischen diesen beiden Profilkarten hinzu mit <kbd>margin-bottom: 1rem</kbd> auf <kbd>.card</kbd>.",
"task": "Diese beiden Profilkarten berühren sich. Füge etwas Abstand unterhalb jeder Karte hinzu, um sie zu trennen.",
"previewHTML": "<article class=\"card\"><h3>Sarah Chen</h3><p>Frontend Developer</p></article><article class=\"card\"><h3>Alex Rivera</h3><p>UX Designer</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); padding: 1rem; border-left: 4px solid steelblue; } .card h3 { margin: 0 0 4px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -65,7 +65,7 @@
{
"type": "property_value",
"value": { "property": "margin-bottom", "expected": "1rem" },
"message": "Setze <kbd>margin-bottom: 1rem</kbd>"
"message": "Welche Eigenschaft schiebt benachbarte Elemente nach unten weg?"
}
]
},
@@ -73,7 +73,7 @@
"id": "box-model-4",
"title": "Box Sizing",
"description": "Standardmäßig setzt <kbd>width</kbd> nur die Inhaltsbreite. Padding und Rahmen werden addiert. Das verursacht Layout-Probleme.<br><br><kbd>box-sizing: border-box</kbd> bezieht Padding und Rahmen in die Breite ein, was das Sizing vorhersehbar macht. Die meisten Entwickler wenden dies auf alle Elemente an.",
"task": "Beide Karten haben <kbd>width: 200px</kbd>. Die linke nutzt Standard-Sizing (content-box) und wird breiter als erwartet. Korrigiere die rechte Karte mit <kbd>box-sizing: border-box</kbd>.",
"task": "Beide Karten haben die gleiche Breite, aber die linke läuft über, weil Padding und Rahmen obendrauf addiert werden. Korrigiere die rechte Karte, damit ihre Größe Padding und Rahmen einschließt.",
"previewHTML": "<div class=\"demo\"><article class=\"card\">Content-box</article><article class=\"card fix\">Border-box</article></div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .demo { display: flex; gap: 1rem; } .card { width: 200px; padding: 1rem; border: 4px solid steelblue; background: white; border-radius: 8px; }",
"sandboxCSS": "",
@@ -86,7 +86,7 @@
{
"type": "property_value",
"value": { "property": "box-sizing", "expected": "border-box" },
"message": "Setze <kbd>box-sizing: border-box</kbd>"
"message": "Welcher Größenmodus bezieht Padding und Rahmen in die Breite des Elements ein?"
}
]
},
@@ -94,7 +94,7 @@
"id": "box-model-5",
"title": "Padding Shorthand",
"description": "Padding akzeptiert 1-4 Werte:<br>• 1 Wert: alle Seiten<br>• 2 Werte: vertikal | horizontal<br>• 4 Werte: oben | rechts | unten | links",
"task": "Dieser Button braucht mehr horizontalen als vertikalen Platz. Setze <kbd>padding: 8px 1rem</kbd> (8px oben/unten, 1rem links/rechts).",
"task": "Dieser Button fühlt sich zu eng an. Gib ihm mehr Platz an den Seiten als oben und unten, mit der Zwei-Werte-Kurzschreibweise.",
"previewHTML": "<button class=\"btn\">Follow</button>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .btn { background: steelblue; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; }",
"sandboxCSS": "",
@@ -107,7 +107,7 @@
{
"type": "regex",
"value": "padding:\\s*8px\\s+1rem",
"message": "Setze <kbd>padding: 8px 1rem</kbd>",
"message": "Verwende die Zwei-Werte-Kurzschreibweise: vertikal zuerst, dann horizontal",
"options": { "caseSensitive": false }
}
]
@@ -116,7 +116,7 @@
"id": "box-model-6",
"title": "Margin Shorthand",
"description": "Margin nutzt das gleiche Kurzschreibweisen-Muster wie Padding. Ein häufiges Muster ist das horizontale Zentrieren von Block-Elementen mit <kbd>margin: 0 auto</kbd>.",
"task": "Zentriere diese Karte horizontal. Setze <kbd>margin: 0 auto</kbd>, um automatisch gleiche links/rechts-Margins zu berechnen.",
"task": "Diese Karte klebt links. Zentriere sie horizontal mit der Margin-Kurzschreibweise und automatischen Seitenabständen.",
"previewHTML": "<article class=\"card\"><h3>Sarah Chen</h3><p>Frontend Developer</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { width: 250px; background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); padding: 1rem; border-left: 4px solid steelblue; } .card h3 { margin: 0 0 4px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -129,7 +129,7 @@
{
"type": "regex",
"value": "margin:\\s*0\\s+auto",
"message": "Setze <kbd>margin: 0 auto</kbd>",
"message": "Verwende die Kurzschreibweise, die gleiche horizontale Abstände automatisch berechnet",
"options": { "caseSensitive": false }
}
]
@@ -138,7 +138,7 @@
"id": "box-model-7",
"title": "Border Radius",
"description": "Obwohl nicht Teil des klassischen Box-Modells, rundet <kbd>border-radius</kbd> die Ecken der Rahmen-Box eines Elements. Verwende <kbd>50%</kbd> bei einem quadratischen Element, um einen Kreis zu erstellen.",
"task": "Mache das Avatar-Bild rund mit <kbd>border-radius: 50%</kbd>.",
"task": "Das quadratische Avatar-Bild soll als perfekter Kreis erscheinen.",
"previewHTML": "<article class=\"card\"><img class=\"avatar\" src=\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ccircle cx='50' cy='35' r='25' fill='%23666'/%3E%3Ccircle cx='50' cy='90' r='40' fill='%23666'/%3E%3C/svg%3E\" alt=\"Avatar\"><h3>Sarah Chen</h3><p>Frontend Developer</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); padding: 1rem; text-align: center; } .avatar { width: 80px; height: 80px; background: #ddd; margin-bottom: 8px; } .card h3 { margin: 0 0 4px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -151,7 +151,7 @@
{
"type": "property_value",
"value": { "property": "border-radius", "expected": "50%" },
"message": "Setze <kbd>border-radius: 50%</kbd>"
"message": "Welche Eigenschaft rundet Ecken? Denke daran, welcher Prozentwert einen Kreis ergibt"
}
]
},
@@ -159,7 +159,7 @@
"id": "box-model-8",
"title": "Complete Card",
"description": "Kombinieren wir alles. Diese Benachrichtigungskarte braucht Styling, um professionell auszusehen.",
"task": "Style die Benachrichtigung: füge <kbd>padding: 1rem</kbd>, <kbd>border-left: 4px solid coral</kbd> und <kbd>border-radius: 4px</kbd> hinzu.",
"task": "Diese Benachrichtigung braucht drei Dinge: inneren Abstand damit der Text nicht gedrängt wirkt, einen farbigen Akzent an der linken Kante und leicht abgerundete Ecken.",
"previewHTML": "<div class=\"alert\"><strong>New message</strong><p>You have 3 unread notifications</p></div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .alert { background: seashell; } .alert strong { color: coral; } .alert p { margin: 4px 0 0; color: #666; }",
"sandboxCSS": "",
@@ -172,18 +172,18 @@
{
"type": "property_value",
"value": { "property": "padding", "expected": "1rem" },
"message": "Setze <kbd>padding: 1rem</kbd>"
"message": "Füge inneren Abstand zur Benachrichtigung hinzu"
},
{
"type": "regex",
"value": "border-left:\\s*4px\\s+solid\\s+coral",
"message": "Setze <kbd>border-left: 4px solid coral</kbd>",
"message": "Füge einen farbigen Akzent an der linken Kante hinzu",
"options": { "caseSensitive": false }
},
{
"type": "property_value",
"value": { "property": "border-radius", "expected": "4px" },
"message": "Setze <kbd>border-radius: 4px</kbd>"
"message": "Runde die Ecken der Benachrichtigung ab"
}
]
}

View File

@@ -9,7 +9,7 @@
"id": "box-model-1",
"title": "Padding",
"description": "Cada elemento en CSS es una caja con cuatro capas: contenido, padding, borde y margen. <strong>Padding</strong> crea espacio entre tu contenido y el borde de la caja.<br><br>Sin padding, el texto se aprieta incómodamente contra los bordes. El padding hace que el contenido sea legible y visualmente equilibrado.<br><br><pre>.card {\n padding: 1rem;\n}</pre>",
"task": "Esta tarjeta de perfil se ve apretada. Añade <kbd>padding: 1rem</kbd> para que el texto tenga espacio para respirar.",
"task": "El texto dentro de esta tarjeta de perfil está pegado a los bordes. Dale algo de espacio interior para respirar.",
"previewHTML": "<article class=\"card\"><h3>Sarah Chen</h3><p>Frontend Developer</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } .card h3 { margin: 0 0 4px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -22,7 +22,7 @@
{
"type": "property_value",
"value": { "property": "padding", "expected": "1rem" },
"message": "Establece <kbd>padding: 1rem</kbd>"
"message": "¿Qué propiedad añade espacio entre el contenido y el borde del elemento?"
}
]
},
@@ -30,7 +30,7 @@
"id": "box-model-2",
"title": "Borders",
"description": "Los bordes crean límites visuales alrededor de los elementos. El atajo <kbd>border</kbd> acepta tres valores: ancho, estilo y color.<br><br>Estilos comunes: <kbd>solid</kbd>, <kbd>dashed</kbd>, <kbd>dotted</kbd>, <kbd>none</kbd>",
"task": "Añade un acento sutil a la izquierda de la tarjeta con <kbd>border-left: 4px solid steelblue</kbd>.",
"task": "Esta tarjeta necesita una línea de acento de color en su borde izquierdo.",
"previewHTML": "<article class=\"card\"><h3>Sarah Chen</h3><p>Frontend Developer</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); padding: 1rem; } .card h3 { margin: 0 0 4px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -43,7 +43,7 @@
{
"type": "regex",
"value": "border-left:\\s*4px\\s+solid\\s+steelblue",
"message": "Establece <kbd>border-left: 4px solid steelblue</kbd>",
"message": "Usa el atajo que define un borde en un solo lado",
"options": { "caseSensitive": false }
}
]
@@ -52,7 +52,7 @@
"id": "box-model-3",
"title": "Margins",
"description": "Los márgenes crean espacio <em>fuera</em> del elemento, separándolo de sus vecinos. Mientras que el padding empuja el contenido hacia adentro, los márgenes empujan otros elementos hacia afuera.",
"task": "Añade espacio entre estas dos tarjetas de perfil con <kbd>margin-bottom: 1rem</kbd> en <kbd>.card</kbd>.",
"task": "Estas dos tarjetas de perfil se están tocando. Añade algo de espacio debajo de cada tarjeta para separarlas.",
"previewHTML": "<article class=\"card\"><h3>Sarah Chen</h3><p>Frontend Developer</p></article><article class=\"card\"><h3>Alex Rivera</h3><p>UX Designer</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); padding: 1rem; border-left: 4px solid steelblue; } .card h3 { margin: 0 0 4px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -65,7 +65,7 @@
{
"type": "property_value",
"value": { "property": "margin-bottom", "expected": "1rem" },
"message": "Establece <kbd>margin-bottom: 1rem</kbd>"
"message": "¿Qué propiedad empuja los elementos vecinos hacia abajo?"
}
]
},
@@ -73,7 +73,7 @@
"id": "box-model-4",
"title": "Box Sizing",
"description": "Por defecto, <kbd>width</kbd> solo establece el ancho del contenido. Padding y bordes se suman al total. Esto causa problemas de diseño.<br><br><kbd>box-sizing: border-box</kbd> incluye padding y borde en el ancho, haciendo el dimensionamiento predecible. La mayoría de desarrolladores aplican esto a todos los elementos.",
"task": "Ambas tarjetas tienen <kbd>width: 200px</kbd>. La izquierda usa el tamaño predeterminado (content-box), haciéndola más ancha de lo esperado. Corrige la tarjeta derecha con <kbd>box-sizing: border-box</kbd>.",
"task": "Ambas tarjetas tienen el mismo ancho, pero la izquierda se desborda porque el padding y el borde se suman encima. Corrige la tarjeta derecha para que su tamaño incluya el padding y el borde.",
"previewHTML": "<div class=\"demo\"><article class=\"card\">Content-box</article><article class=\"card fix\">Border-box</article></div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .demo { display: flex; gap: 1rem; } .card { width: 200px; padding: 1rem; border: 4px solid steelblue; background: white; border-radius: 8px; }",
"sandboxCSS": "",
@@ -86,7 +86,7 @@
{
"type": "property_value",
"value": { "property": "box-sizing", "expected": "border-box" },
"message": "Establece <kbd>box-sizing: border-box</kbd>"
"message": "¿Qué modo de tamaño incluye padding y borde en el ancho del elemento?"
}
]
},
@@ -94,7 +94,7 @@
"id": "box-model-5",
"title": "Padding Shorthand",
"description": "Padding acepta 1-4 valores:<br>• 1 valor: todos los lados<br>• 2 valores: vertical | horizontal<br>• 4 valores: arriba | derecha | abajo | izquierda",
"task": "Este botón necesita más espacio horizontal que vertical. Establece <kbd>padding: 8px 1rem</kbd> (8px arriba/abajo, 1rem izquierda/derecha).",
"task": "Este botón se siente muy apretado. Dale más espacio en los lados que arriba y abajo, usando el atajo de dos valores.",
"previewHTML": "<button class=\"btn\">Follow</button>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .btn { background: steelblue; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; }",
"sandboxCSS": "",
@@ -107,7 +107,7 @@
{
"type": "regex",
"value": "padding:\\s*8px\\s+1rem",
"message": "Establece <kbd>padding: 8px 1rem</kbd>",
"message": "Usa el atajo de dos valores: vertical primero, luego horizontal",
"options": { "caseSensitive": false }
}
]
@@ -116,7 +116,7 @@
"id": "box-model-6",
"title": "Margin Shorthand",
"description": "Margin usa el mismo patrón de atajo que padding. Un patrón común es centrar elementos de bloque horizontalmente con <kbd>margin: 0 auto</kbd>.",
"task": "Centra esta tarjeta horizontalmente. Establece <kbd>margin: 0 auto</kbd> para calcular automáticamente márgenes iguales izquierda/derecha.",
"task": "Esta tarjeta está pegada a la izquierda. Céntrala horizontalmente usando el atajo de margen con márgenes laterales automáticos.",
"previewHTML": "<article class=\"card\"><h3>Sarah Chen</h3><p>Frontend Developer</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { width: 250px; background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); padding: 1rem; border-left: 4px solid steelblue; } .card h3 { margin: 0 0 4px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -129,7 +129,7 @@
{
"type": "regex",
"value": "margin:\\s*0\\s+auto",
"message": "Establece <kbd>margin: 0 auto</kbd>",
"message": "Usa el atajo que calcula márgenes horizontales iguales automáticamente",
"options": { "caseSensitive": false }
}
]
@@ -138,7 +138,7 @@
"id": "box-model-7",
"title": "Border Radius",
"description": "Aunque no es parte del modelo de caja clásico, <kbd>border-radius</kbd> redondea las esquinas de la caja de borde de un elemento. Usa <kbd>50%</kbd> en un elemento cuadrado para crear un círculo.",
"task": "Haz la imagen del avatar circular con <kbd>border-radius: 50%</kbd>.",
"task": "La imagen cuadrada del avatar debería aparecer como un círculo perfecto.",
"previewHTML": "<article class=\"card\"><img class=\"avatar\" src=\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ccircle cx='50' cy='35' r='25' fill='%23666'/%3E%3Ccircle cx='50' cy='90' r='40' fill='%23666'/%3E%3C/svg%3E\" alt=\"Avatar\"><h3>Sarah Chen</h3><p>Frontend Developer</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); padding: 1rem; text-align: center; } .avatar { width: 80px; height: 80px; background: #ddd; margin-bottom: 8px; } .card h3 { margin: 0 0 4px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -151,7 +151,7 @@
{
"type": "property_value",
"value": { "property": "border-radius", "expected": "50%" },
"message": "Establece <kbd>border-radius: 50%</kbd>"
"message": "¿Qué propiedad redondea las esquinas? Piensa en qué porcentaje crea un círculo"
}
]
},
@@ -159,7 +159,7 @@
"id": "box-model-8",
"title": "Complete Card",
"description": "Combinemos todo. Esta tarjeta de notificación necesita estilo para verse profesional.",
"task": "Estiliza la notificación: añade <kbd>padding: 1rem</kbd>, <kbd>border-left: 4px solid coral</kbd> y <kbd>border-radius: 4px</kbd>.",
"task": "Esta notificación necesita tres cosas: espacio interior para que el texto no esté apretado, un acento de color en el borde izquierdo y esquinas ligeramente redondeadas.",
"previewHTML": "<div class=\"alert\"><strong>New message</strong><p>You have 3 unread notifications</p></div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .alert { background: seashell; } .alert strong { color: coral; } .alert p { margin: 4px 0 0; color: #666; }",
"sandboxCSS": "",
@@ -172,18 +172,18 @@
{
"type": "property_value",
"value": { "property": "padding", "expected": "1rem" },
"message": "Establece <kbd>padding: 1rem</kbd>"
"message": "Añade espacio interior a la notificación"
},
{
"type": "regex",
"value": "border-left:\\s*4px\\s+solid\\s+coral",
"message": "Establece <kbd>border-left: 4px solid coral</kbd>",
"message": "Añade un acento de color en el borde izquierdo",
"options": { "caseSensitive": false }
},
{
"type": "property_value",
"value": { "property": "border-radius", "expected": "4px" },
"message": "Establece <kbd>border-radius: 4px</kbd>"
"message": "Suaviza las esquinas de la notificación"
}
]
}

View File

@@ -9,7 +9,7 @@
"id": "flexbox-1",
"title": "Container",
"description": "Before flexbox, creating even simple layouts required floats, positioning hacks, or table-based layouts. Flexbox (Flexible Box Layout) revolutionized CSS by providing a one-dimensional layout system designed specifically for distributing space and aligning content.<br><br><strong>How it works:</strong> When you set <kbd>display: flex</kbd> on an element, it becomes a <em>flex container</em>. Its direct children automatically become <em>flex items</em> that flow along a main axis (horizontal by default). This single property transforms stacked block elements into a horizontal row.<br><br><strong>The two axes:</strong><br>• <em>Main axis</em> The primary direction items flow (row = left→right)<br>• <em>Cross axis</em> Perpendicular to main (row = top→bottom)<br><br><pre>.nav {\n display: flex;\n}</pre>",
"task": "This navigation menu stacks vertically. Add <kbd>display: flex</kbd> to <kbd>.nav</kbd> to arrange the links horizontally.",
"task": "The navigation links are stacking vertically. Make them display side by side in a horizontal row.",
"previewHTML": "<nav class=\"nav\"><a href=\"#\">Home</a><a href=\"#\">Products</a><a href=\"#\">About</a><a href=\"#\">Contact</a></nav>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; margin: 0; } .nav { background: #1a1a2e; padding: 1rem; } .nav a { color: white; text-decoration: none; padding: 8px 1rem; border-radius: 4px; } .nav a:hover { background: rgba(255,255,255,0.1); }",
"sandboxCSS": "",
@@ -22,7 +22,7 @@
{
"type": "property_value",
"value": { "property": "display", "expected": "flex" },
"message": "Set <kbd>display: flex</kbd>"
"message": "Try changing the display mode to create a flex container"
}
]
},
@@ -30,7 +30,7 @@
"id": "flexbox-2",
"title": "Gap",
"description": "The <kbd>gap</kbd> property adds consistent spacing between flex items without needing margins. It only creates space between items, not around the edges.",
"task": "Add <kbd>gap: 1rem</kbd> to space out the navigation links evenly.",
"task": "The navigation links are crammed together with no breathing room. Add 1rem of spacing between them.",
"previewHTML": "<nav class=\"nav\"><a href=\"#\">Home</a><a href=\"#\">Products</a><a href=\"#\">About</a><a href=\"#\">Contact</a></nav>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; margin: 0; } .nav { background: #1a1a2e; padding: 1rem; display: flex; } .nav a { color: white; text-decoration: none; padding: 8px 1rem; border-radius: 4px; background: rgba(255,255,255,0.1); }",
"sandboxCSS": "",
@@ -43,7 +43,7 @@
{
"type": "property_value",
"value": { "property": "gap", "expected": "1rem" },
"message": "Set <kbd>gap: 1rem</kbd>"
"message": "Use the property that adds spacing between flex items"
}
]
},
@@ -51,7 +51,7 @@
"id": "flexbox-3",
"title": "Justify Content",
"description": "<kbd>justify-content</kbd> distributes items along the main axis. Common values:<br>• <kbd>flex-start</kbd> pack items at the start<br>• <kbd>flex-end</kbd> pack at the end<br>• <kbd>center</kbd> center items<br>• <kbd>space-between</kbd> equal space between items<br>• <kbd>space-around</kbd> equal space around items",
"task": "Push the \"Login\" button to the right by setting <kbd>justify-content: space-between</kbd> on the nav.",
"task": "The Login button should sit on the far right, with the other links staying on the left. Distribute the space between them.",
"previewHTML": "<nav class=\"nav\"><div class=\"links\"><a href=\"#\">Home</a><a href=\"#\">Products</a><a href=\"#\">About</a></div><a href=\"#\" class=\"login\">Login</a></nav>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; margin: 0; } .nav { background: #1a1a2e; padding: 1rem; display: flex; } .links { display: flex; gap: 8px; } .nav a { color: white; text-decoration: none; padding: 8px 1rem; border-radius: 4px; } .nav a:hover { background: rgba(255,255,255,0.1); } .login { background: steelblue; }",
"sandboxCSS": "",
@@ -64,7 +64,7 @@
{
"type": "property_value",
"value": { "property": "justify-content", "expected": "space-between" },
"message": "Set <kbd>justify-content: space-between</kbd>"
"message": "Use the property that distributes items along the main axis"
}
]
},
@@ -72,7 +72,7 @@
"id": "flexbox-4",
"title": "Align Items",
"description": "<kbd>align-items</kbd> controls alignment on the cross axis (vertical when flex-direction is row). Values include:<br>• <kbd>stretch</kbd> stretch to fill (default)<br>• <kbd>flex-start</kbd> align to top<br>• <kbd>flex-end</kbd> align to bottom<br>• <kbd>center</kbd> center vertically",
"task": "The logo and nav links have different heights. Center them vertically with <kbd>align-items: center</kbd>.",
"task": "The logo and nav links sit at different heights. Center them vertically so they line up.",
"previewHTML": "<header class=\"header\"><div class=\"logo\">ACME</div><nav><a href=\"#\">Products</a><a href=\"#\">Pricing</a><a href=\"#\">Docs</a></nav></header>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; margin: 0; } .header { background: white; padding: 1rem 2rem; display: flex; justify-content: space-between; border-bottom: 1px solid #eee; } .logo { font-size: 1.5rem; font-weight: bold; color: steelblue; } nav { display: flex; gap: 1rem; } nav a { color: #333; text-decoration: none; font-size: 0.9rem; }",
"sandboxCSS": "",
@@ -85,7 +85,7 @@
{
"type": "property_value",
"value": { "property": "align-items", "expected": "center" },
"message": "Set <kbd>align-items: center</kbd>"
"message": "Use the property that controls cross-axis alignment"
}
]
},
@@ -93,7 +93,7 @@
"id": "flexbox-5",
"title": "Flex Wrap",
"description": "By default, flex items squeeze onto one line. <kbd>flex-wrap: wrap</kbd> allows items to flow onto multiple lines when they run out of space.",
"task": "These cards overflow the container. Add <kbd>flex-wrap: wrap</kbd> to allow them to wrap to new rows.",
"task": "The cards overflow the container instead of fitting within it. Allow the items to flow onto new rows when they run out of space.",
"previewHTML": "<div class=\"cards\"><article class=\"card\">Card 1</article><article class=\"card\">Card 2</article><article class=\"card\">Card 3</article><article class=\"card\">Card 4</article><article class=\"card\">Card 5</article><article class=\"card\">Card 6</article></div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .cards { display: flex; gap: 1rem; } .card { background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); min-width: 120px; text-align: center; }",
"sandboxCSS": "",
@@ -106,7 +106,7 @@
{
"type": "property_value",
"value": { "property": "flex-wrap", "expected": "wrap" },
"message": "Set <kbd>flex-wrap: wrap</kbd>"
"message": "Use the property that allows flex items to wrap onto new lines"
}
]
},
@@ -114,7 +114,7 @@
"id": "flexbox-6",
"title": "Flex Grow",
"description": "The <kbd>flex</kbd> property on items controls how they grow and shrink. <kbd>flex: 1</kbd> makes an item grow to fill available space. Multiple items with <kbd>flex: 1</kbd> share space equally.",
"task": "Make the search input expand to fill available space by setting <kbd>flex: 1</kbd> on <kbd>.search</kbd>.",
"task": "The search input is too narrow. Make it stretch to fill all the remaining space in the toolbar.",
"previewHTML": "<div class=\"toolbar\"><input class=\"search\" type=\"text\" placeholder=\"Search...\"><button class=\"btn\">Search</button><button class=\"btn\">Filters</button></div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .toolbar { display: flex; gap: 8px; padding: 1rem; background: #f5f5f5; border-radius: 8px; } .search { padding: 8px 1rem; border: 1px solid #ddd; border-radius: 4px; font-size: 1rem; } .btn { padding: 8px 1rem; background: steelblue; color: white; border: none; border-radius: 4px; cursor: pointer; }",
"sandboxCSS": "",
@@ -125,9 +125,9 @@
"previewContainer": "preview-area",
"validations": [
{
"type": "property_value",
"value": { "property": "flex", "expected": "1" },
"message": "Set <kbd>flex: 1</kbd>"
"type": "regex",
"value": "(flex\\s*:\\s*1|flex-grow\\s*:\\s*1)",
"message": "Use the property that makes a flex item grow to fill available space"
}
]
}

View File

@@ -9,7 +9,7 @@
"id": "box-model-1",
"title": "Padding",
"description": "Każdy element w CSS to pudełko z czterema warstwami: treść, padding, ramka i margines. <strong>Padding</strong> tworzy przestrzeń między treścią a krawędzią pudełka.<br><br>Bez paddingu tekst przylega niezręcznie do ramek. Padding sprawia, że treść jest czytelna i wizualnie zbalansowana.<br><br><pre>.card {\n padding: 1rem;\n}</pre>",
"task": "Ta karta profilu wygląda na ciasną. Dodaj <kbd>padding: 1rem</kbd>, aby tekst miał miejsce do oddychania.",
"task": "Tekst wewnątrz tej karty profilu jest przyciśnięty do krawędzi. Daj mu trochę wewnętrznej przestrzeni do oddychania.",
"previewHTML": "<article class=\"card\"><h3>Sarah Chen</h3><p>Frontend Developer</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } .card h3 { margin: 0 0 4px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -22,7 +22,7 @@
{
"type": "property_value",
"value": { "property": "padding", "expected": "1rem" },
"message": "Ustaw <kbd>padding: 1rem</kbd>"
"message": "Która właściwość dodaje przestrzeń między treścią a krawędzią elementu?"
}
]
},
@@ -30,7 +30,7 @@
"id": "box-model-2",
"title": "Borders",
"description": "Ramki tworzą wizualne granice wokół elementów. Skrót <kbd>border</kbd> przyjmuje trzy wartości: szerokość, styl i kolor.<br><br>Popularne style: <kbd>solid</kbd>, <kbd>dashed</kbd>, <kbd>dotted</kbd>, <kbd>none</kbd>",
"task": "Dodaj subtelny lewy akcent do karty za pomocą <kbd>border-left: 4px solid steelblue</kbd>.",
"task": "Ta karta mogłaby mieć kolorową linię akcentową wzdłuż lewej krawędzi.",
"previewHTML": "<article class=\"card\"><h3>Sarah Chen</h3><p>Frontend Developer</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); padding: 1rem; } .card h3 { margin: 0 0 4px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -43,7 +43,7 @@
{
"type": "regex",
"value": "border-left:\\s*4px\\s+solid\\s+steelblue",
"message": "Ustaw <kbd>border-left: 4px solid steelblue</kbd>",
"message": "Użyj skrótu, który ustawia ramkę tylko po jednej stronie",
"options": { "caseSensitive": false }
}
]
@@ -52,7 +52,7 @@
"id": "box-model-3",
"title": "Margins",
"description": "Marginesy tworzą przestrzeń <em>na zewnątrz</em> elementu, oddzielając go od sąsiadów. Podczas gdy padding przesuwa treść do wewnątrz, marginesy odpychają inne elementy.",
"task": "Dodaj przestrzeń między tymi dwiema kartami profilu za pomocą <kbd>margin-bottom: 1rem</kbd> na <kbd>.card</kbd>.",
"task": "Te dwie karty profilu stykają się ze sobą. Dodaj trochę przestrzeni pod każdą kartą, aby je rozdzielić.",
"previewHTML": "<article class=\"card\"><h3>Sarah Chen</h3><p>Frontend Developer</p></article><article class=\"card\"><h3>Alex Rivera</h3><p>UX Designer</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); padding: 1rem; border-left: 4px solid steelblue; } .card h3 { margin: 0 0 4px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -65,7 +65,7 @@
{
"type": "property_value",
"value": { "property": "margin-bottom", "expected": "1rem" },
"message": "Ustaw <kbd>margin-bottom: 1rem</kbd>"
"message": "Która właściwość odpycha sąsiednie elementy w dół?"
}
]
},
@@ -73,7 +73,7 @@
"id": "box-model-4",
"title": "Box Sizing",
"description": "Domyślnie <kbd>width</kbd> ustawia tylko szerokość treści. Padding i ramki są dodawane. To powoduje problemy z układem.<br><br><kbd>box-sizing: border-box</kbd> włącza padding i ramkę do szerokości, czyniąc rozmiary przewidywalnymi. Większość programistów stosuje to do wszystkich elementów.",
"task": "Obie karty mają <kbd>width: 200px</kbd>. Lewa używa domyślnego rozmiaru (content-box), stając się szersza niż oczekiwano. Napraw prawą kartę za pomocą <kbd>box-sizing: border-box</kbd>.",
"task": "Obie karty mają tę samą szerokość, ale lewa wychodzi poza, bo padding i ramka są dodawane na wierzch. Napraw prawą kartę, aby jej rozmiar obejmował padding i ramkę.",
"previewHTML": "<div class=\"demo\"><article class=\"card\">Content-box</article><article class=\"card fix\">Border-box</article></div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .demo { display: flex; gap: 1rem; } .card { width: 200px; padding: 1rem; border: 4px solid steelblue; background: white; border-radius: 8px; }",
"sandboxCSS": "",
@@ -86,7 +86,7 @@
{
"type": "property_value",
"value": { "property": "box-sizing", "expected": "border-box" },
"message": "Ustaw <kbd>box-sizing: border-box</kbd>"
"message": "Który tryb rozmiaru uwzględnia padding i ramkę w szerokości elementu?"
}
]
},
@@ -94,7 +94,7 @@
"id": "box-model-5",
"title": "Padding Shorthand",
"description": "Padding przyjmuje 1-4 wartości:<br>• 1 wartość: wszystkie strony<br>• 2 wartości: pionowo | poziomo<br>• 4 wartości: góra | prawo | dół | lewo",
"task": "Ten przycisk potrzebuje więcej miejsca poziomego niż pionowego. Ustaw <kbd>padding: 8px 1rem</kbd> (8px góra/dół, 1rem lewo/prawo).",
"task": "Ten przycisk jest zbyt ciasny. Daj mu więcej przestrzeni po bokach niż na górze i dole, używając skrótu dwuwartościowego.",
"previewHTML": "<button class=\"btn\">Follow</button>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .btn { background: steelblue; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; }",
"sandboxCSS": "",
@@ -107,7 +107,7 @@
{
"type": "regex",
"value": "padding:\\s*8px\\s+1rem",
"message": "Ustaw <kbd>padding: 8px 1rem</kbd>",
"message": "Użyj skrótu dwuwartościowego: najpierw pionowo, potem poziomo",
"options": { "caseSensitive": false }
}
]
@@ -116,7 +116,7 @@
"id": "box-model-6",
"title": "Margin Shorthand",
"description": "Margines używa tego samego wzorca skrótu co padding. Typowym wzorcem jest poziome centrowanie elementów blokowych za pomocą <kbd>margin: 0 auto</kbd>.",
"task": "Wycentruj tę kartę poziomo. Ustaw <kbd>margin: 0 auto</kbd>, aby automatycznie obliczyć równe marginesy lewo/prawo.",
"task": "Ta karta jest przyklejona do lewej. Wycentruj ją poziomo, używając skrótu marginesu z automatycznymi marginesami bocznymi.",
"previewHTML": "<article class=\"card\"><h3>Sarah Chen</h3><p>Frontend Developer</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { width: 250px; background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); padding: 1rem; border-left: 4px solid steelblue; } .card h3 { margin: 0 0 4px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -129,7 +129,7 @@
{
"type": "regex",
"value": "margin:\\s*0\\s+auto",
"message": "Ustaw <kbd>margin: 0 auto</kbd>",
"message": "Użyj skrótu, który automatycznie oblicza równe marginesy poziome",
"options": { "caseSensitive": false }
}
]
@@ -138,7 +138,7 @@
"id": "box-model-7",
"title": "Border Radius",
"description": "Chociaż nie jest częścią klasycznego modelu pudełkowego, <kbd>border-radius</kbd> zaokrągla rogi ramki elementu. Użyj <kbd>50%</kbd> na kwadratowym elemencie, aby utworzyć koło.",
"task": "Zrób okrągły obrazek awatara za pomocą <kbd>border-radius: 50%</kbd>.",
"task": "Kwadratowy obrazek awatara powinien wyglądać jak idealne koło.",
"previewHTML": "<article class=\"card\"><img class=\"avatar\" src=\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ccircle cx='50' cy='35' r='25' fill='%23666'/%3E%3Ccircle cx='50' cy='90' r='40' fill='%23666'/%3E%3C/svg%3E\" alt=\"Avatar\"><h3>Sarah Chen</h3><p>Frontend Developer</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); padding: 1rem; text-align: center; } .avatar { width: 80px; height: 80px; background: #ddd; margin-bottom: 8px; } .card h3 { margin: 0 0 4px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -151,7 +151,7 @@
{
"type": "property_value",
"value": { "property": "border-radius", "expected": "50%" },
"message": "Ustaw <kbd>border-radius: 50%</kbd>"
"message": "Która właściwość zaokrągla rogi? Pomyśl, jaki procent tworzy koło"
}
]
},
@@ -159,7 +159,7 @@
"id": "box-model-8",
"title": "Complete Card",
"description": "Połączmy wszystko razem. Ta karta powiadomienia potrzebuje stylowania, żeby wyglądać profesjonalnie.",
"task": "Ostyluj powiadomienie: dodaj <kbd>padding: 1rem</kbd>, <kbd>border-left: 4px solid coral</kbd> i <kbd>border-radius: 4px</kbd>.",
"task": "To powiadomienie potrzebuje trzech rzeczy: wewnętrznej przestrzeni, żeby tekst nie był ściśnięty, kolorowego akcentu na lewej krawędzi i lekko zaokrąglonych rogów.",
"previewHTML": "<div class=\"alert\"><strong>New message</strong><p>You have 3 unread notifications</p></div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .alert { background: seashell; } .alert strong { color: coral; } .alert p { margin: 4px 0 0; color: #666; }",
"sandboxCSS": "",
@@ -172,18 +172,18 @@
{
"type": "property_value",
"value": { "property": "padding", "expected": "1rem" },
"message": "Ustaw <kbd>padding: 1rem</kbd>"
"message": "Dodaj wewnętrzny odstęp do powiadomienia"
},
{
"type": "regex",
"value": "border-left:\\s*4px\\s+solid\\s+coral",
"message": "Ustaw <kbd>border-left: 4px solid coral</kbd>",
"message": "Dodaj kolorowy akcent na lewej krawędzi",
"options": { "caseSensitive": false }
},
{
"type": "property_value",
"value": { "property": "border-radius", "expected": "4px" },
"message": "Ustaw <kbd>border-radius: 4px</kbd>"
"message": "Wygładź rogi powiadomienia"
}
]
}

View File

@@ -9,7 +9,7 @@
"id": "box-model-1",
"title": "Padding",
"description": "Кожен елемент у CSS - це блок з чотирма шарами: контент, відступ (padding), межа та поле. <strong>Padding</strong> створює простір для дихання між вашим контентом і краєм блоку.<br><br>Без padding текст незручно притискається до меж. Padding робить контент читабельним і візуально збалансованим.<br><br><pre>.card {\n padding: 1rem;\n}</pre>",
"task": "Ця картка профілю виглядає тісною. Додайте <kbd>padding: 1rem</kbd>, щоб текст мав простір для дихання.",
"task": "Текст всередині цієї картки профілю притиснутий до країв. Дайте йому трохи внутрішнього простору для дихання.",
"previewHTML": "<article class=\"card\"><h3>Sarah Chen</h3><p>Frontend Developer</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } .card h3 { margin: 0 0 4px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -22,7 +22,7 @@
{
"type": "property_value",
"value": { "property": "padding", "expected": "1rem" },
"message": "Встановіть <kbd>padding: 1rem</kbd>"
"message": "Яка властивість додає простір між контентом і краєм елемента?"
}
]
},
@@ -30,7 +30,7 @@
"id": "box-model-2",
"title": "Borders",
"description": "Межі створюють візуальні границі навколо елементів. Скорочення <kbd>border</kbd> приймає три значення: ширину, стиль і колір.<br><br>Поширені стилі: <kbd>solid</kbd>, <kbd>dashed</kbd>, <kbd>dotted</kbd>, <kbd>none</kbd>",
"task": "Додайте тонкий лівий акцент до картки за допомогою <kbd>border-left: 4px solid steelblue</kbd>.",
"task": "Ця картка потребує кольорової акцентної лінії вздовж лівого краю.",
"previewHTML": "<article class=\"card\"><h3>Sarah Chen</h3><p>Frontend Developer</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); padding: 1rem; } .card h3 { margin: 0 0 4px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -43,7 +43,7 @@
{
"type": "regex",
"value": "border-left:\\s*4px\\s+solid\\s+steelblue",
"message": "Встановіть <kbd>border-left: 4px solid steelblue</kbd>",
"message": "Використайте скорочення, яке встановлює межу лише з одного боку",
"options": { "caseSensitive": false }
}
]
@@ -52,7 +52,7 @@
"id": "box-model-3",
"title": "Margins",
"description": "Поля створюють простір <em>зовні</em> елемента, відділяючи його від сусідів. Тоді як padding штовхає контент всередину, поля відштовхують інші елементи.",
"task": "Додайте простір між цими двома картками профілю за допомогою <kbd>margin-bottom: 1rem</kbd> на <kbd>.card</kbd>.",
"task": "Ці дві картки профілю торкаються одна одної. Додайте трохи простору під кожною карткою, щоб розділити їх.",
"previewHTML": "<article class=\"card\"><h3>Sarah Chen</h3><p>Frontend Developer</p></article><article class=\"card\"><h3>Alex Rivera</h3><p>UX Designer</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); padding: 1rem; border-left: 4px solid steelblue; } .card h3 { margin: 0 0 4px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -65,7 +65,7 @@
{
"type": "property_value",
"value": { "property": "margin-bottom", "expected": "1rem" },
"message": "Встановіть <kbd>margin-bottom: 1rem</kbd>"
"message": "Яка властивість відштовхує сусідні елементи знизу?"
}
]
},
@@ -73,7 +73,7 @@
"id": "box-model-4",
"title": "Box Sizing",
"description": "За замовчуванням <kbd>width</kbd> встановлює лише ширину контенту. Padding і межі додаються до загальної суми. Це спричиняє проблеми з макетом.<br><br><kbd>box-sizing: border-box</kbd> включає padding і межу у ширину, роблячи розмір передбачуваним. Більшість розробників застосовують це до всіх елементів.",
"task": "Обидві картки мають <kbd>width: 200px</kbd>. Ліва використовує стандартний розмір (content-box), стаючи ширшою за очікуване. Виправте праву картку за допомогою <kbd>box-sizing: border-box</kbd>.",
"task": "Обидві картки мають однакову ширину, але ліва виходить за межі, бо відступи та межі додаються зверху. Виправте праву картку, щоб її розмір включав відступи та межі.",
"previewHTML": "<div class=\"demo\"><article class=\"card\">Content-box</article><article class=\"card fix\">Border-box</article></div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .demo { display: flex; gap: 1rem; } .card { width: 200px; padding: 1rem; border: 4px solid steelblue; background: white; border-radius: 8px; }",
"sandboxCSS": "",
@@ -86,7 +86,7 @@
{
"type": "property_value",
"value": { "property": "box-sizing", "expected": "border-box" },
"message": "Встановіть <kbd>box-sizing: border-box</kbd>"
"message": "Який режим розміру включає padding і межу в ширину елемента?"
}
]
},
@@ -94,7 +94,7 @@
"id": "box-model-5",
"title": "Padding Shorthand",
"description": "Padding приймає 1-4 значення:<br>• 1 значення: всі сторони<br>• 2 значення: вертикально | горизонтально<br>• 4 значення: верх | право | низ | ліво",
"task": "Ця кнопка потребує більше горизонтального простору, ніж вертикального. Встановіть <kbd>padding: 8px 1rem</kbd> (8px верх/низ, 1rem ліво/право).",
"task": "Ця кнопка занадто тісна. Дайте їй більше простору з боків, ніж зверху та знизу, використовуючи скорочення з двома значеннями.",
"previewHTML": "<button class=\"btn\">Follow</button>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .btn { background: steelblue; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; }",
"sandboxCSS": "",
@@ -107,7 +107,7 @@
{
"type": "regex",
"value": "padding:\\s*8px\\s+1rem",
"message": "Встановіть <kbd>padding: 8px 1rem</kbd>",
"message": "Використайте скорочення з двома значеннями: спочатку вертикальне, потім горизонтальне",
"options": { "caseSensitive": false }
}
]
@@ -116,7 +116,7 @@
"id": "box-model-6",
"title": "Margin Shorthand",
"description": "Margin використовує той самий шаблон скорочення, що й padding. Поширений шаблон - горизонтальне центрування блокових елементів за допомогою <kbd>margin: 0 auto</kbd>.",
"task": "Відцентруйте цю картку горизонтально. Встановіть <kbd>margin: 0 auto</kbd>, щоб автоматично обчислити рівні ліві/праві поля.",
"task": "Ця картка приліпла до лівого краю. Відцентруйте її горизонтально, використовуючи скорочення полів з автоматичними бічними полями.",
"previewHTML": "<article class=\"card\"><h3>Sarah Chen</h3><p>Frontend Developer</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { width: 250px; background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); padding: 1rem; border-left: 4px solid steelblue; } .card h3 { margin: 0 0 4px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -129,7 +129,7 @@
{
"type": "regex",
"value": "margin:\\s*0\\s+auto",
"message": "Встановіть <kbd>margin: 0 auto</kbd>",
"message": "Використайте скорочення, яке автоматично розраховує рівні горизонтальні поля",
"options": { "caseSensitive": false }
}
]
@@ -138,7 +138,7 @@
"id": "box-model-7",
"title": "Border Radius",
"description": "Хоча не є частиною класичної блокової моделі, <kbd>border-radius</kbd> заокруглює кути межі елемента. Використовуйте <kbd>50%</kbd> на квадратному елементі, щоб створити коло.",
"task": "Зробіть зображення аватара круглим за допомогою <kbd>border-radius: 50%</kbd>.",
"task": "Квадратне зображення аватара має виглядати як ідеальне коло.",
"previewHTML": "<article class=\"card\"><img class=\"avatar\" src=\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ccircle cx='50' cy='35' r='25' fill='%23666'/%3E%3Ccircle cx='50' cy='90' r='40' fill='%23666'/%3E%3C/svg%3E\" alt=\"Avatar\"><h3>Sarah Chen</h3><p>Frontend Developer</p></article>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); padding: 1rem; text-align: center; } .avatar { width: 80px; height: 80px; background: #ddd; margin-bottom: 8px; } .card h3 { margin: 0 0 4px; } .card p { margin: 0; color: #666; }",
"sandboxCSS": "",
@@ -151,7 +151,7 @@
{
"type": "property_value",
"value": { "property": "border-radius", "expected": "50%" },
"message": "Встановіть <kbd>border-radius: 50%</kbd>"
"message": "Яка властивість заокруглює кути? Подумайте, який відсоток створює коло"
}
]
},
@@ -159,7 +159,7 @@
"id": "box-model-8",
"title": "Complete Card",
"description": "Об'єднаймо все разом. Ця картка сповіщення потребує стилізації, щоб виглядати професійно.",
"task": "Стилізуйте сповіщення: додайте <kbd>padding: 1rem</kbd>, <kbd>border-left: 4px solid coral</kbd> та <kbd>border-radius: 4px</kbd>.",
"task": "Це сповіщення потребує трьох речей: внутрішнього простору, щоб текст не був стиснутий, кольорового акценту на лівому краю та злегка заокруглених кутів.",
"previewHTML": "<div class=\"alert\"><strong>New message</strong><p>You have 3 unread notifications</p></div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .alert { background: seashell; } .alert strong { color: coral; } .alert p { margin: 4px 0 0; color: #666; }",
"sandboxCSS": "",
@@ -172,18 +172,18 @@
{
"type": "property_value",
"value": { "property": "padding", "expected": "1rem" },
"message": "Встановіть <kbd>padding: 1rem</kbd>"
"message": "Додайте внутрішній відступ до сповіщення"
},
{
"type": "regex",
"value": "border-left:\\s*4px\\s+solid\\s+coral",
"message": "Встановіть <kbd>border-left: 4px solid coral</kbd>",
"message": "Додайте кольоровий акцент на лівому краю",
"options": { "caseSensitive": false }
},
{
"type": "property_value",
"value": { "property": "border-radius", "expected": "4px" },
"message": "Встановіть <kbd>border-radius: 4px</kbd>"
"message": "Згладьте кути сповіщення"
}
]
}

65
package-lock.json generated
View File

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

View File

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

View File

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

View File

@@ -0,0 +1,76 @@
# Implementation Plan
## Objective
Rewrite all 6 flexbox lesson task descriptions to describe the desired visual outcome instead of giving the exact CSS declaration. Update validation messages to hint without revealing answers, and accept alternative valid solutions where applicable.
## Approach
This is a content-only change to a single JSON file (`lessons/flexbox.json`). Each lesson needs three edits:
1. **Task text**: Replace copy-pasteable CSS declarations with outcome-oriented descriptions
2. **Validation messages**: Replace answer-revealing messages with pedagogical hints
3. **Validations array**: Add alternative accepted solutions where multiple CSS approaches achieve the same visual result
The lesson `description` fields (which teach concepts with code examples) remain unchanged — they are the learning material, not the exercise prompt.
## File Mapping
| File | Action | Description |
|------|--------|-------------|
| `lessons/flexbox.json` | modify | Rewrite `task` and validation `message` fields for all 6 lessons; add alternative validations for flexbox-6 |
No new files need to be created. No validator code changes needed — the existing `property_value` and `regex` validation types already support everything required.
## Detailed Changes Per Lesson
### flexbox-1 (Container)
- **Task**: Describe that nav links stack vertically and should display side by side
- **Validation msg**: Hint at display property for flex layout
- **Alt solutions**: None — `display: flex` is the only correct answer (inline-flex changes block behavior)
### flexbox-2 (Gap)
- **Task**: Describe that links are crammed together and need 1rem of spacing between them
- **Validation msg**: Hint at the gap property
- **Alt solutions**: None — `gap: 1rem` is the specific expected value
### flexbox-3 (Justify Content)
- **Task**: Describe that Login button should be pushed to the far right, with nav links on the left
- **Validation msg**: Hint at main-axis distribution property
- **Alt solutions**: None — `justify-content: space-between` is the only property that works when targeting `.nav`
### flexbox-4 (Align Items)
- **Task**: Describe the visual misalignment and ask for vertical centering
- **Validation msg**: Hint at cross-axis alignment property
- **Alt solutions**: None — `align-items: center` is the correct answer
### flexbox-5 (Flex Wrap)
- **Task**: Describe cards overflowing and needing to flow onto new rows
- **Validation msg**: Hint at wrapping property
- **Alt solutions**: None — `flex-wrap: wrap` is the only answer
### flexbox-6 (Flex Grow)
- **Task**: Describe that the search input should stretch to fill remaining space
- **Validation msg**: Hint at flex growth property
- **Alt solutions**: Accept both `flex: 1` and `flex-grow: 1` via regex validation
## Architecture Decisions
1. **No validator code changes**: The existing `regex` validation type can handle alternative solutions for flexbox-6. No need to add a new validation type.
2. **Keep values in tasks where needed**: Some tasks mention target values like "1rem" since the validator checks exact values and students need to know the amount. The key change is removing the *property name* from the task.
3. **Solution field unchanged**: The `solution` field is used for the "show solution" feature and should remain as the canonical answer.
4. **codePrefix unchanged**: The existing codePrefix already shows the selector context (e.g., `.nav {`), which is enough guidance for students.
## Risks
| Risk | Likelihood | Mitigation |
|------|-----------|------------|
| Tasks become too vague for beginners | Low | Descriptions still teach the property; tasks describe specific visual outcomes |
| Alternative regex validation too permissive | Low | Regex will be specific to `flex:\s*1` and `flex-grow:\s*1` patterns |
| Validation messages too cryptic | Low | Messages will hint at the property category without giving the exact declaration |
## Testing Strategy
1. **Run existing test suite**: `npm run test` — all tests should pass since no code or module structure changes
2. **Manual verification**: Validate that each rewritten task accurately describes the visual outcome shown in the preview
3. **JSON schema validation**: Ensure `lessons/flexbox.json` still conforms to the module schema

View File

@@ -0,0 +1,35 @@
# fix: remove answers from flexbox task descriptions (copy-paste score 95%)
**Issue**: [libretech/code-crispies#3](https://git.librete.ch/libretech/code-crispies/issues/3)
**State**: open
**Author**: libretech
**Labels**: none
**Complexity**: simple
## Issue Body
Pedagogy audit: All 6 flexbox exercises give the exact CSS declaration in the task text. Students type without understanding. Rewrite tasks to describe the DESIRED OUTCOME instead of the exact code. Example: 'Add display: flex' → 'The navigation links stack vertically. Make them display side by side.' Accept multiple valid solutions in validations.
## Current State
All 6 lessons in `lessons/flexbox.json` have task descriptions that include the exact CSS declaration students need to type:
| Lesson | Current Task (gives away answer) |
|--------|----------------------------------|
| flexbox-1 | "Add `display: flex` to `.nav`" |
| flexbox-2 | "Add `gap: 1rem` to space out..." |
| flexbox-3 | "setting `justify-content: space-between` on the nav" |
| flexbox-4 | "Center them vertically with `align-items: center`" |
| flexbox-5 | "Add `flex-wrap: wrap` to allow them to wrap" |
| flexbox-6 | "setting `flex: 1` on `.search`" |
Validation error messages also give away answers (e.g., "Set `display: flex`").
## Acceptance Criteria
1. All 6 flexbox task descriptions rewritten to describe the desired visual outcome, not the exact CSS code
2. Students cannot copy-paste from the task into the editor to pass
3. Validation error messages updated to provide hints without revealing the exact declaration
4. Where applicable, validations accept multiple valid CSS solutions (e.g., `flex: 1` and `flex-grow: 1`)
5. Existing tests continue to pass
6. Lesson descriptions (which teach the concepts) remain unchanged

View File

@@ -0,0 +1,13 @@
# Tasks
## Phase 1: Core Content Changes
- [X] Task 1.1: Rewrite task text for all 6 flexbox lessons to describe visual outcomes [P]
- [X] Task 1.2: Rewrite validation error messages to hint without revealing answers [P]
## Phase 2: Alternative Validations
- [X] Task 2.1: Add regex validation for flexbox-6 to accept both `flex: 1` and `flex-grow: 1`
## Phase 3: Validation
- [X] Task 3.1: Run existing test suite to confirm no regressions
- [X] Task 3.2: Verify flexbox.json still conforms to module schema
- [X] Task 3.3: Run lesson format check (`npm run format.lessons`)

View File

@@ -0,0 +1,77 @@
# Implementation Plan
## Objective
Rewrite validation error messages in the box-model and colors lesson modules (and their localizations) so they guide learners toward the answer instead of revealing it. This breaks the "fail-then-copy" loop identified in the pedagogy audit.
## Approach
1. Rewrite each validation `message` field in the English box-model and colors JSON files using question/hint phrasing that describes the *concept* without stating the exact property-value pair
2. Use the flexbox module's existing messages as the style guide
3. Apply equivalent translations to all 5 localized box-model files (ar, de, es, pl, uk)
4. Run the format-lessons script and tests to verify nothing breaks
5. Commit as a docs/content fix (`fix:` conventional commit)
## File Mapping
### Files to Modify
| File | Action | Changes |
|------|--------|---------|
| `lessons/01-box-model.json` | modify | Rewrite 11 validation messages |
| `lessons/03-colors.json` | modify | Rewrite 4 validation messages |
| `lessons/ar/01-box-model.json` | modify | Translate 11 new guiding messages to Arabic |
| `lessons/de/01-box-model.json` | modify | Translate 11 new guiding messages to German |
| `lessons/es/01-box-model.json` | modify | Translate 11 new guiding messages to Spanish |
| `lessons/pl/01-box-model.json` | modify | Translate 11 new guiding messages to Polish |
| `lessons/uk/01-box-model.json` | modify | Translate 11 new guiding messages to Ukrainian |
### Files NOT Changed
- `lessons/flexbox.json` — already uses guiding messages
- All localized flexbox files — already correct
- No colors localizations exist
## Architecture Decisions
1. **Message style**: Use the same imperative hint style as flexbox ("Use the property that...", "Try the property that...") rather than pure questions. This is consistent with the existing codebase and gives just enough direction without revealing the answer.
2. **No `<kbd>` tags in new messages**: The current answer-revealing messages use `<kbd>` to format exact code. The new guiding messages should avoid `<kbd>` since they won't contain code literals — they describe concepts.
3. **Preserve validation logic**: Only the `message` field changes. The `type`, `value`, `options`, and all other fields remain untouched.
4. **Localization approach**: Translate the English guiding messages into each target language, maintaining the same hint/question style. Keep CSS property names untranslated (they are code).
## Message Mapping (English)
| Lesson | Current Message | New Message |
|--------|----------------|-------------|
| box-model-1 | Set `padding: 1rem` | Which property adds space between content and the element's edge? |
| box-model-2 | Set `border-left: 4px solid steelblue` | Use the shorthand that sets a border on just one side |
| box-model-3 | Set `margin-bottom: 1rem` | Which property pushes neighboring elements away from the bottom? |
| box-model-4 | Set `box-sizing: border-box` | Which sizing mode includes padding and border in the element's width? |
| box-model-5 | Set `padding: 8px 1rem` | Use the two-value shorthand: vertical first, then horizontal |
| box-model-6 | Set `margin: 0 auto` | Use the shorthand that auto-calculates equal horizontal margins |
| box-model-7 | Set `border-radius: 50%` | Which property rounds corners? Think about what percentage makes a circle |
| box-model-8 v1 | Set `padding: 1rem` | Add inner spacing to the notification |
| box-model-8 v2 | Set `border-left: 4px solid coral` | Add a colored accent on the left edge |
| box-model-8 v3 | Set `border-radius: 4px` | Soften the corners of the notification |
| colors-1 | Set `background-color: seashell` | Which property fills the area behind the content? |
| colors-2 | Set `color: coral` | Which property changes the text color? |
| colors-3 | Set `border-color: coral` | Which property changes just the border's color without redefining the whole border? |
| colors-4 | Set `background-color: #ffd700` | Use the same background property, but with a hex code this time |
## Risks
| Risk | Likelihood | Mitigation |
|------|-----------|------------|
| Translation quality for 5 languages | Medium | Use consistent patterns; CSS property names stay in English; keep messages short |
| Messages too vague, frustrating learners | Low | Each message still hints at the concept/direction; task descriptions already contain the answer for early lessons |
| Schema validation failure | Very Low | Only `message` string changes; no structural changes |
## Testing Strategy
1. **Automated**: Run `npm run test` — existing unit tests validate the validator logic, not message content, so they should pass unchanged
2. **Automated**: Run `npm run format.lessons` — ensures JSON formatting is correct
3. **Manual verification**: Spot-check that each new message conceptually matches its lesson without revealing the answer
4. **Schema validation**: JSON files reference the schema; any structural errors would be caught by the editor/tooling

View File

@@ -0,0 +1,57 @@
# fix: validation error messages reveal the solution instead of guiding learning
**Issue:** [#4](https://git.librete.ch/libretech/code-crispies/issues/4)
**Repository:** libretech/code-crispies
**Author:** libretech
**State:** open
**Labels:** none
## Issue Body
Pedagogy audit: 88% of exercises reveal the answer in error messages, creating a fail-then-copy loop. Change validation messages from 'Set padding: 1rem' to 'Which property adds space between content and the element edge?' This applies across all modules — start with flexbox, box-model, and colors (the 3 worst offenders).
## Scope
The three priority modules:
1. **Flexbox** (`lessons/flexbox.json`) — already uses guiding messages (0 messages need changes)
2. **Box Model** (`lessons/01-box-model.json`) — 11 validation messages reveal exact answers
3. **Colors** (`lessons/03-colors.json`) — 4 validation messages reveal exact answers
Localized versions that need corresponding updates:
- `lessons/ar/01-box-model.json`
- `lessons/de/01-box-model.json`
- `lessons/es/01-box-model.json`
- `lessons/pl/01-box-model.json`
- `lessons/uk/01-box-model.json`
No localized versions exist for colors.
## Acceptance Criteria
- [ ] All validation messages in box-model module guide the learner instead of revealing the answer
- [ ] All validation messages in colors module guide the learner instead of revealing the answer
- [ ] Messages use question or hint phrasing (e.g., "Which property..." or "Try the property that...")
- [ ] Messages never include the exact property-value pair that solves the exercise
- [ ] All 5 localized box-model files receive equivalent translated guiding messages
- [ ] Existing tests continue to pass (message content is not tested, only validation logic)
- [ ] Lesson JSON files remain valid against the module schema
## Current vs Desired Pattern
**Current (answer-revealing):**
```
"message": "Set <kbd>padding: 1rem</kbd>"
```
**Desired (guiding):**
```
"message": "Which property adds space between the content and the element's edge?"
```
## Prior Art
The flexbox module already follows the desired pattern. Its messages serve as the style reference:
- "Try changing the display mode to create a flex container"
- "Use the property that adds spacing between flex items"
- "Use the property that distributes items along the main axis"

View File

@@ -0,0 +1,20 @@
# Tasks
## Phase 1: English Lesson Files
- [X] Task 1.1: Rewrite 11 validation messages in `lessons/01-box-model.json`
- [X] Task 1.2: Rewrite 4 validation messages in `lessons/03-colors.json`
## Phase 2: Localized Box-Model Files
- [X] Task 2.1: Update validation messages in `lessons/ar/01-box-model.json` (Arabic) [P]
- [X] Task 2.2: Update validation messages in `lessons/de/01-box-model.json` (German) [P]
- [X] Task 2.3: Update validation messages in `lessons/es/01-box-model.json` (Spanish) [P]
- [X] Task 2.4: Update validation messages in `lessons/pl/01-box-model.json` (Polish) [P]
- [X] Task 2.5: Update validation messages in `lessons/uk/01-box-model.json` (Ukrainian) [P]
## Phase 3: Validation
- [X] Task 3.1: Run `npm run format.lessons` to normalize JSON formatting
- [X] Task 3.2: Run `npm run test` to verify no regressions
- [X] Task 3.3: Spot-check that no message reveals the exact answer
## Phase 4: Commit
- [X] Task 4.1: Commit all changes with conventional commit message

View File

@@ -0,0 +1,106 @@
# Implementation Plan
## Objective
Rewrite task descriptions in the Colors (4 lessons) and Box Model (8 lessons x 6 locales) modules so they describe desired visual outcomes rather than giving exact CSS declarations. For colors, also update validations to accept multiple valid color values.
## Approach
This follows the same pattern as the flexbox fix (PR #5). Two types of changes:
1. **Colors module**: Rewrite tasks AND update validations from `property_value` (single answer) to `regex` (multiple valid colors). This is because the issue explicitly says "accept multiple valid solutions" and colors naturally have many equivalent options.
2. **Box Model module**: Rewrite tasks only. Validation messages already use pedagogical hints. Box model properties have specific correct answers (e.g., `box-sizing: border-box` has no alternative), so validations stay as-is.
## File Mapping
| File | Action | Description |
|------|--------|-------------|
| `lessons/03-colors.json` | modify | Rewrite 4 tasks + change 4 validations from `property_value` to `regex` |
| `lessons/01-box-model.json` | modify | Rewrite 8 task fields |
| `lessons/ar/01-box-model.json` | modify | Rewrite 8 task fields (Arabic) |
| `lessons/de/01-box-model.json` | modify | Rewrite 8 task fields (German) |
| `lessons/es/01-box-model.json` | modify | Rewrite 8 task fields (Spanish) |
| `lessons/pl/01-box-model.json` | modify | Rewrite 8 task fields (Polish) |
| `lessons/uk/01-box-model.json` | modify | Rewrite 8 task fields (Ukrainian) |
No validator code changes needed — existing `regex` type already supports multi-value patterns.
## Detailed Changes
### Colors Module
#### colors-1 (Background Color)
- **Task**: Describe that notification card looks bare, needs a soft warm background
- **Validation**: Change from `property_value` (seashell only) to `regex` accepting warm named colors (seashell, linen, mistyrose, lavenderblush, cornsilk, oldlace, papayawhip, antiquewhite, bisque, peachpuff)
- **Message**: Hint at background-color property
#### colors-2 (Text Color)
- **Task**: Describe that title needs to pop with a warm accent color
- **Validation**: Change from `property_value` (coral only) to `regex` accepting warm accent colors (coral, tomato, orangered, indianred, salmon, darksalmon)
- **Message**: Hint at color property
#### colors-3 (Border Color)
- **Task**: Describe that card border needs a warm accent color
- **Validation**: Change from `property_value` (coral only) to `regex` accepting warm accent colors (coral, tomato, orangered, indianred, salmon, darksalmon, crimson)
- **Message**: Hint at border-color property
#### colors-4 (Hex Colors)
- **Task**: Describe wanting a gold/yellow badge background, mentioning hex format since that's the lesson's teaching point
- **Validation**: Change from `property_value` (#ffd700 only) to `regex` accepting gold hex variants (#ffd700, #ffcc00, #ffc107, #f0c000) and also the named color `gold`
- **Message**: Hint at using a hex code for background-color
### Box Model Module (per-lesson, applied across all 6 locales)
#### box-model-1 (Padding)
- **Current**: "Add `padding: 1rem`..."
- **New**: Describe that text is pressed against the edges and needs inner breathing room
#### box-model-2 (Borders)
- **Current**: "Add `border-left: 4px solid steelblue`"
- **New**: Describe wanting a colored accent line on the left side of the card
#### box-model-3 (Margins)
- **Current**: "Add `margin-bottom: 1rem`"
- **New**: Describe that the two cards are touching and need space between them
#### box-model-4 (Box Sizing)
- **Current**: "Fix with `box-sizing: border-box`"
- **New**: Describe the visual problem (right card overflows) and ask to fix its sizing model
#### box-model-5 (Padding Shorthand)
- **Current**: "Set `padding: 8px 1rem`"
- **New**: Describe the button needing more horizontal than vertical space, mention the two-value shorthand concept
#### box-model-6 (Margin Shorthand)
- **Current**: "Set `margin: 0 auto`"
- **New**: Describe the card being left-aligned and needing to be horizontally centered
#### box-model-7 (Border Radius)
- **Current**: "Make with `border-radius: 50%`"
- **New**: Describe the square avatar needing to appear as a circle
#### box-model-8 (Complete Card)
- **Current**: Lists all 3 exact property declarations
- **New**: Describe three visual goals: inner spacing, left accent line, softened corners
## Architecture Decisions
1. **No validator code changes**: The existing `regex` validation type handles multi-value matching.
2. **Colors get multi-value validations**: Colors naturally have equivalents (coral vs tomato). Accept a curated set of named colors per lesson.
3. **Box model keeps exact validations**: Properties like `padding: 1rem` or `box-sizing: border-box` have only one correct answer. The task text changes are sufficient.
4. **Solution fields unchanged**: The `solution` field shows the canonical answer and is unrelated to copy-paste behavior.
5. **codePrefix unchanged**: Already shows the selector context.
## Risks
| Risk | Likelihood | Mitigation |
|------|-----------|------------|
| Color regex too permissive/restrictive | Medium | Curate a small set of 6-10 named colors per lesson that visually work in the preview |
| Locale translations lose nuance | Low | Follow the same structure: describe the visual outcome in each language |
| Box model tasks become too vague | Low | Keep mentioning the visual problem — students have the description for concept reference |
## Testing Strategy
1. Run `npm run test` — all existing tests should pass
2. Run `npm run format.lessons` — ensure JSON files are properly formatted
3. Verify JSON schema conformance for all modified files

View File

@@ -0,0 +1,50 @@
# fix: rewrite colors and box-model task descriptions to remove copy-paste answers
**Issue**: [libretech/code-crispies#9](https://git.librete.ch/libretech/code-crispies/issues/9)
**State**: open
**Author**: libretech
**Labels**: none
**Complexity**: medium
## Issue Body
Pedagogy audit Runde 3: Colors (copy-paste 90%) and Box Model (copy-paste 85%) are the next worst modules after flexbox was fixed. Same pattern — task says 'Add background-color: coral' and student just types it. Rewrite to describe desired outcome: 'The card background should be a warm color.' Accept multiple valid solutions.
## Current State
### Colors Module (`lessons/03-colors.json`) — English only, 4 lessons
| Lesson | Current Task (gives away answer) |
|--------|----------------------------------|
| colors-1 | "Add `background-color: seashell`" |
| colors-2 | "Add `color: coral`" |
| colors-3 | "Add `border-color: coral`" |
| colors-4 | "Add `background-color: #ffd700`" |
All 4 validations use `property_value` with exact expected values — only one answer accepted.
### Box Model Module (`lessons/01-box-model.json`) — 6 locales (en, ar, de, es, pl, uk), 8 lessons
| Lesson | Current Task (gives away answer) |
|--------|----------------------------------|
| box-model-1 | "Add `padding: 1rem`" |
| box-model-2 | "Add `border-left: 4px solid steelblue`" |
| box-model-3 | "Add `margin-bottom: 1rem`" |
| box-model-4 | "Fix with `box-sizing: border-box`" |
| box-model-5 | "Set `padding: 8px 1rem`" |
| box-model-6 | "Set `margin: 0 auto`" |
| box-model-7 | "Make with `border-radius: 50%`" |
| box-model-8 | Lists all 3 properties verbatim |
Box model validation messages are already well-written (hint without revealing). The `task` fields contain `<kbd>` tags with exact answers.
## Acceptance Criteria
1. All 4 colors task descriptions rewritten to describe desired visual outcomes
2. All 8 box-model task descriptions rewritten to describe desired visual outcomes
3. Students cannot copy-paste from the task into the editor to pass
4. Colors validations accept multiple valid CSS color values where appropriate
5. Box-model validation messages remain as-is (already hint without revealing)
6. All 5 localized box-model files updated to match the English rewrite pattern
7. Existing tests continue to pass
8. Lesson descriptions (which teach the concepts) remain unchanged

View File

@@ -0,0 +1,22 @@
# Tasks
## Phase 1: Colors Module
- [X] Task 1.1: Rewrite task text for all 4 colors lessons to describe visual outcomes
- [X] Task 1.2: Change colors validations from property_value to regex accepting multiple valid color names
- [X] Task 1.3: Update colors validation error messages to hint without revealing answers
## Phase 2: Box Model Module (English)
- [X] Task 2.1: Rewrite task text for all 8 box-model lessons to describe visual outcomes
- [X] Task 2.2: Review box-model validation messages (already good, update only if needed)
## Phase 3: Box Model Translations [P]
- [X] Task 3.1: Rewrite task text in Arabic (ar/01-box-model.json) [P]
- [X] Task 3.2: Rewrite task text in German (de/01-box-model.json) [P]
- [X] Task 3.3: Rewrite task text in Spanish (es/01-box-model.json) [P]
- [X] Task 3.4: Rewrite task text in Polish (pl/01-box-model.json) [P]
- [X] Task 3.5: Rewrite task text in Ukrainian (uk/01-box-model.json) [P]
## Phase 4: Validation
- [X] Task 4.1: Run existing test suite to confirm no regressions
- [X] Task 4.2: Run lesson format check (npm run format.lessons)
- [X] Task 4.3: Verify all modified JSON files conform to module schema

View File

@@ -0,0 +1,65 @@
# Implementation Plan
## Objective
Rewrite the 4 CSS Filters lessons in `lessons/11-filters.json` so tasks describe visual outcomes instead of giving exact CSS code, and validations accept multiple valid answers.
## Approach
Follow the same pattern established in issue #9 (colors/box-model rewrite):
1. Rewrite each `task` field to describe the desired visual effect
2. Replace exact-match validations (`property_value`, `contains`) with `regex` validations that accept a range of values
3. Update validation `message` fields to give pedagogical hints without revealing answers
4. Keep `description`, `previewHTML`, `previewBaseCSS`, `codePrefix`, `codeSuffix`, and `solution` unchanged
## File Mapping
| File | Action | Description |
|------|--------|-------------|
| `lessons/11-filters.json` | modify | Rewrite tasks and validations for all 4 lessons |
## Lesson-by-Lesson Plan
### filters-1: Blur Filter
- **Current task:** "Blur the background image using `filter: blur(4px)`."
- **New task:** Describe frosted-glass effect, mention blur radius range (2px-8px)
- **Validation:** `regex` matching `filter:\s*blur\((2|3|4|5|6|7|8)px\)` — accepts 2px through 8px
- **Message:** Hint about the `filter` property and `blur()` function
### filters-2: Grayscale Filter
- **Current task:** "Make the image grayscale with `filter: grayscale(100%)`."
- **New task:** Describe removing all color to create a black-and-white effect
- **Validation:** `regex` matching `filter:\s*grayscale\(100%\)` — grayscale only makes sense at 100% for "fully desaturated"
- **Message:** Hint about which filter function removes color
### filters-3: Brightness Filter
- **Current task:** "Brighten the card with `filter: brightness(120%)`."
- **New task:** Describe making the card appear brighter/more vivid, accept range 110%-150%
- **Validation:** `regex` matching `filter:\s*brightness\(1[1-5]0%\)` — accepts 110% through 150%
- **Message:** Hint about the brightness function and values above 100%
### filters-4: Drop Shadow
- **Current task:** "Add a drop shadow with `filter: drop-shadow(4px 4px 8px gray)`."
- **New task:** Describe adding a soft shadow behind the star to give it depth
- **Validation:** `regex` matching `filter:\s*drop-shadow\(` with reasonable offset/blur values
- **Message:** Hint about the drop-shadow filter function
## Architecture Decisions
- Use `regex` validation type (not `property_value`) to allow multiple acceptable values, consistent with the colors/box-model rewrite
- Use `options: { "caseSensitive": false }` on all regex validations for consistency
- Keep solution fields unchanged as reference answers
## Risks
| Risk | Mitigation |
|------|------------|
| Regex too permissive | Test edge cases; only accept pedagogically reasonable values |
| Regex too restrictive | Allow generous ranges; err on the side of accepting creative answers |
| Breaking existing progress | localStorage keys are based on lesson IDs which are unchanged |
## Testing Strategy
- Run existing test suite (`npm test`) to verify no regressions
- Validate JSON file against schema
- Manual review of regex patterns for correctness

View File

@@ -0,0 +1,30 @@
# fix: rewrite CSS Filters tasks to describe visual outcomes instead of exact code
**Issue:** [#12](https://git.librete.ch/libretech/code-crispies/issues/12)
**Repository:** libretech/code-crispies
**Author:** libretech
**State:** open
**Labels:** none
## Problem
Pedagogy audit: the Filters module has a 100% copy-paste score. Every exercise gives the exact CSS declaration in the task text, so students can complete lessons without understanding the concepts.
## Requirements
- Rewrite ONLY the filters module (`lessons/11-filters.json`) task descriptions to describe the desired visual effect instead of the exact code
- Accept multiple valid values in validations (e.g., accept blur values between 2px and 8px instead of only 4px)
- Do NOT change any other module
## Example
- **Before:** "Add `filter: blur(4px)`"
- **After:** "Blur the background image to create a frosted-glass effect. Use a blur radius between 2px and 8px."
## Acceptance Criteria
1. All 4 filter lesson tasks describe visual outcomes, not exact CSS
2. Validations accept a range of valid values using regex patterns
3. Validation messages provide pedagogical hints without revealing answers
4. No changes to any file outside `lessons/11-filters.json`
5. Existing tests continue to pass

View File

@@ -0,0 +1,12 @@
# Tasks
## Phase 1: Core Implementation
- [X] Task 1.1: Rewrite filters-1 (Blur) task and validations [P]
- [X] Task 1.2: Rewrite filters-2 (Grayscale) task and validations [P]
- [X] Task 1.3: Rewrite filters-3 (Brightness) task and validations [P]
- [X] Task 1.4: Rewrite filters-4 (Drop Shadow) task and validations [P]
## Phase 2: Validation
- [X] Task 2.1: Validate JSON against schema
- [X] Task 2.2: Run existing test suite
- [X] Task 2.3: Format lesson file with Prettier

View File

@@ -1,6 +1,6 @@
import { LessonEngine } from "./impl/LessonEngine.js";
import { CodeEditor, crispyEditorTheme } from "./impl/CodeEditor.js";
import { renderLesson, renderModuleList, renderLevelIndicator, updateActiveLessonInSidebar } from "./helpers/renderer.js";
import { renderLesson, renderModuleList, renderLevelIndicator, updateActiveLessonInSidebar, renderDifficultyBadge } from "./helpers/renderer.js";
import { loadModules } from "./config/lessons.js";
import { initI18n, t, getLanguage, setLanguage, applyTranslations } from "./i18n.js";
import { parseHash, updateHash, replaceHash, getShareableUrl, RouteType, navigateTo } from "./helpers/router.js";
@@ -164,7 +164,8 @@ const elements = {
refFooterLessonLinks: document.getElementById("ref-footer-lesson-links"),
sectionFooterLessonLinks: document.getElementById("section-footer-lesson-links"),
progressFill: document.getElementById("progress-fill"),
progressText: document.getElementById("progress-text"),
progressCurrent: document.getElementById("progress-current"),
progressTotal: document.getElementById("progress-total"),
milestonesContainer: document.getElementById("milestones"),
resetBtn: document.getElementById("reset-btn"),
disableFeedbackToggle: document.getElementById("disable-feedback-toggle"),
@@ -317,14 +318,17 @@ let lastMilestoneReached = 0;
function updateProgressDisplay() {
const stats = lessonEngine.getProgressStats();
// Update progress bar - shows overall progress with full gradient
const progressPercent = stats.percentComplete || 1;
// Update progress bar - shows progress towards next milestone
// CSS variable scales gradient so only first X% of colors show
const progressPercent = stats.progressToNext || 1;
elements.progressFill.style.width = `${progressPercent}%`;
elements.progressFill.style.setProperty('--progress-percent', progressPercent);
// Update progress text - show completed of total lessons
elements.progressText.textContent = t("progressTextMilestone", {
completed: stats.totalCompleted,
// Update progress current - show progress towards next milestone
elements.progressCurrent.textContent = `${stats.totalCompleted}/${stats.nextMilestone}`;
// Update progress total - show total lessons
elements.progressTotal.textContent = t("progressTotal", {
total: stats.totalLessons
});
@@ -569,6 +573,16 @@ function updateEditorForMode(mode) {
label: "CSS Editor",
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: {
placeholder: "<style>\n /* CSS here */\n</style>\n\n<!-- HTML here -->",
label: "HTML & CSS",
@@ -645,21 +659,28 @@ function loadCurrentLesson() {
lesson
);
// Render difficulty badge
renderDifficultyBadge(elements.lessonTitleRow, lesson);
// 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) {
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
if (engineState.isCompleted) {
elements.runBtn.querySelector("span").textContent = t("rerun");
// Add completion badge if not present
if (!document.querySelector(".completion-badge")) {
// Add completion badge to difficulty-wrapper if not present
const wrapper = document.querySelector(".difficulty-wrapper");
if (wrapper && !wrapper.querySelector(".completion-badge")) {
const badge = document.createElement("span");
badge.className = "completion-badge";
badge.textContent = t("completed");
elements.lessonTitleRow.appendChild(badge);
wrapper.appendChild(badge);
}
// Show gradient border and glow for completed lessons
@@ -668,7 +689,7 @@ function loadCurrentLesson() {
} else {
elements.runBtn.querySelector("span").textContent = t("run");
// Remove completion badge and border if exists
// Remove completion badge if exists
const badge = document.querySelector(".completion-badge");
if (badge) badge.remove();
elements.previewWrapper?.classList.remove("completed-glow");
@@ -755,15 +776,11 @@ function updateNavigationButtons() {
const engineState = lessonEngine.getCurrentState();
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
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.nextBtn.classList.toggle("btn-disabled", !engineState.canGoNext);
elements.nextBtn.classList.toggle("btn-disabled", isPlayground || !engineState.canGoNext);
}
function nextLesson() {
@@ -865,7 +882,7 @@ function loadRandomTemplate() {
}
function runCode() {
const userCode = codeEditor ? codeEditor.getValue() : "";
const userCode = codeEditor ? codeEditor.getEditableValue() : "";
const engineState = lessonEngine.getCurrentState();
const isPlayground = engineState.lesson?.mode === "playground";
@@ -1402,6 +1419,143 @@ const sectionContent = {
</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 adds interactivity to HTML pages—responding to clicks, updating content dynamically, validating forms, and much more. Every modern browser includes a JavaScript engine, making it the most widely deployed programming language in the world.</p>
<p>These beginner lessons cover the fundamentals: declaring variables, selecting and modifying DOM elements, and handling user events. Each concept builds on the previous one, giving you the tools to make any web page interactive.</p>
</div>
<div class="topic-row">
<div class="topic-text">
<h2>Variables & Data Types</h2>
<p>JavaScript uses <code>const</code> for values that won't change and <code>let</code> for values that will. Template literals with backticks make it easy to embed expressions in strings using <code>\${...}</code> syntax.</p>
<p>Arrays store ordered collections in square brackets. Objects store key-value pairs in curly braces. These are the building blocks of every JavaScript program.</p>
<a href="#js-variables/0" class="topic-link">Learn JS Variables</a>
</div>
<div class="topic-code">
<div class="code-block">
<pre><code>const name = "Alice";
let count = 0;
count = count + 1;
const msg = \`Hello, \${name}!\`;
const colors = ["red", "green"];</code></pre>
</div>
</div>
</div>
<div class="topic-row">
<div class="topic-text">
<h2>DOM Manipulation</h2>
<p>The DOM (Document Object Model) is how JavaScript sees your HTML. Use <code>document.querySelector()</code> to find elements by CSS selector, then modify them with properties like <code>textContent</code>, <code>style</code>, and <code>classList</code>.</p>
<a href="#js-dom/0" class="topic-link">Practice DOM Methods</a>
</div>
<div class="topic-code">
<div class="code-block">
<pre><code>const title = document.querySelector('h1');
title.textContent = "New Title";
title.style.color = "coral";
title.classList.add("active");</code></pre>
</div>
</div>
</div>
<div class="topic-row">
<div class="topic-text">
<h2>Event Handling</h2>
<p>Events let your code respond to user actions. Use <code>addEventListener()</code> to run a function when something happens—a click, a keystroke, or an input change. The callback receives an event object with details about what happened.</p>
<a href="#js-events/0" class="topic-link">Handle Events</a>
</div>
<div class="topic-code">
<div class="code-block">
<pre><code>const btn = document.querySelector('.btn');
btn.addEventListener('click', () => {
alert('Clicked!');
});</code></pre>
</div>
</div>
</div>
`
};
@@ -1918,6 +2072,105 @@ const referenceContent = {
</section>
<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;
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;
title = `${sectionName} Lessons - CODE CRISPIES | Learn ${sectionName}`;
description = `Learn ${sectionName} through interactive coding exercises. Hands-on practice with instant feedback.`;
@@ -1987,7 +2240,8 @@ function updatePageMeta(route) {
selectors: "CSS Selectors",
flexbox: "Flexbox",
grid: "CSS Grid",
html: "HTML Elements"
html: "HTML Elements",
markdown: "Markdown Syntax"
};
const refName = refNames[route.refId] || "Reference";
title = `${refName} Reference - CODE CRISPIES`;
@@ -2119,7 +2373,7 @@ function showLandingPage() {
*/
function renderFooterLessonLinks() {
const modules = lessonEngine.modules || [];
const sectionGroups = { css: [], html: [] };
const sectionGroups = { css: [], html: [], javascript: [] };
modules.forEach((module) => {
if (module.excludeFromProgress) return;
@@ -2156,7 +2410,7 @@ function renderFooterLessonLinks() {
* Update progress indicators on landing page
*/
function updateLandingProgress() {
["css", "html", "tailwind"].forEach((sectionId) => {
["css", "html", "markdown", "javascript"].forEach((sectionId) => { // tailwind temporarily disabled
const progressEl = document.getElementById(`${sectionId}-progress`);
if (progressEl) {
const sectionModules = getModulesBySection(lessonEngine.modules, sectionId);
@@ -2242,7 +2496,7 @@ function showReferencePage(refId) {
const activeRef = refId || "css";
// 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");
// Track reference page view
@@ -2448,6 +2702,11 @@ function init() {
// Initialize i18n before anything else
initI18n();
// Set dynamic year in footer
document.querySelectorAll(".current-year").forEach((el) => {
el.textContent = new Date().getFullYear();
});
loadUserSettings();
// Restore cached lesson content immediately to avoid "Loading..." flash
@@ -2476,6 +2735,11 @@ function init() {
elements.closeSidebar.addEventListener("click", closeSidebar);
elements.sidebarBackdrop.addEventListener("click", closeSidebar);
// Sidebar nav links (mobile) - close sidebar on click
document.querySelectorAll(".sidebar-nav-link").forEach((link) => {
link.addEventListener("click", closeSidebar);
});
// Logo click - navigate to home landing
elements.logoLink.addEventListener("click", (e) => {
e.preventDefault();

View File

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

View File

@@ -30,6 +30,10 @@ import gradientsEN from "../../lessons/09-gradients.json";
import filtersEN from "../../lessons/11-filters.json";
import positioningEN from "../../lessons/12-positioning.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 goodbyeEN from "../../lessons/99-goodbye.json";
@@ -162,6 +166,12 @@ const moduleStoreEN = [
htmlFieldsetEN,
htmlDatalistEN,
htmlTablesEN,
// Markdown
markdownBasicsEN,
// JavaScript
jsVariablesEN,
jsDomEN,
jsEventsEN,
// Outro
goodbyeEN,
playgroundEN
@@ -201,6 +211,12 @@ const moduleStoreDE = [
htmlFieldsetDE,
htmlDatalistDE,
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
goodbyeEN,
playgroundEN
@@ -240,6 +256,12 @@ const moduleStorePL = [
htmlFieldsetPL,
htmlDatalistPL,
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
goodbyeEN,
playgroundEN
@@ -279,6 +301,12 @@ const moduleStoreES = [
htmlFieldsetES,
htmlDatalistES,
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
goodbyeEN,
playgroundEN
@@ -318,6 +346,12 @@ const moduleStoreAR = [
htmlFieldsetAR,
htmlDatalistAR,
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
goodbyeEN,
playgroundEN
@@ -357,6 +391,12 @@ const moduleStoreUK = [
htmlFieldsetUK,
htmlDatalistUK,
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
goodbyeEN,
playgroundEN

View File

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

View File

@@ -2,6 +2,50 @@
* Renderer - Handles UI updates for the CSS learning platform
*/
import { t } from "../i18n.js";
import { getModuleSection, getSection, getSectionList } from "../config/sections.js";
/**
* Compute lesson difficulty based on lesson structure
* - Easy: selector is provided in codePrefix (student only writes properties)
* - Medium: student writes a simple selector (single element/class)
* - Hard: student writes compound selectors (descendant, chained classes, type+class)
* @param {Object} lesson - The lesson object
* @returns {"easy"|"medium"|"hard"} The computed difficulty
*/
export function computeLessonDifficulty(lesson) {
const codePrefix = lesson.codePrefix || "";
const solution = lesson.solution || "";
// If codePrefix contains an opening brace, selector is provided → Easy
if (codePrefix.includes("{")) {
return "easy";
}
// No codePrefix with selector - check the solution complexity
// Hard: descendant selectors (space before {), chained classes (.a.b), type+class (a.class)
const selectorMatch = solution.match(/^([^{]+)\{/);
if (selectorMatch) {
const selector = selectorMatch[1].trim();
// Descendant selector: has space (e.g., ".nav a", ".card p")
if (/\S\s+\S/.test(selector)) {
return "hard";
}
// Chained classes: multiple dots without space (e.g., ".btn.primary")
if ((selector.match(/\./g) || []).length > 1) {
return "hard";
}
// Type + class: element followed by dot (e.g., "a.btn", "div.card")
if (/^[a-z]+\.[a-z]/i.test(selector)) {
return "hard";
}
}
// Simple selector → Medium
return "medium";
}
// Feedback elements cache
let feedbackElement = null;
@@ -29,8 +73,24 @@ export function renderModuleList(container, modules, onSelectModule, onSelectLes
}
}
// Group modules by section for headers
let currentSectionId = null;
// Create list items for each module
modules.forEach((module) => {
// Insert section header when section changes
const sectionId = getModuleSection(module);
if (sectionId !== currentSectionId && !module.excludeFromProgress) {
currentSectionId = sectionId;
const section = getSection(sectionId);
if (section) {
const header = document.createElement("h3");
header.className = "sidebar-section-header";
header.textContent = section.title;
header.style.borderLeftColor = section.color;
container.appendChild(header);
}
}
// Create module container
// Use native <details>/<summary> for expand/collapse
const moduleContainer = document.createElement("details");
@@ -138,6 +198,42 @@ export function renderLesson(titleEl, descriptionEl, taskEl, previewEl, prefixEl
// The LessonEngine will handle this when it's first set
}
/**
* Render the difficulty badge (right-aligned in title row)
* @param {HTMLElement} container - The container element (lesson-title-row)
* @param {Object} lesson - The lesson object
*/
export function renderDifficultyBadge(container, lesson) {
// Remove existing difficulty wrapper if any
const existingWrapper = container.querySelector(".difficulty-wrapper");
if (existingWrapper) {
existingWrapper.remove();
}
// Compute difficulty
const difficulty = computeLessonDifficulty(lesson);
// Create wrapper for right-alignment
const wrapper = document.createElement("span");
wrapper.className = "difficulty-wrapper";
// Create badge element with three bars
const badge = document.createElement("span");
badge.className = `difficulty-badge difficulty-${difficulty}`;
badge.setAttribute("aria-label", t(`difficulty_${difficulty}_label`));
badge.setAttribute("title", t(`difficulty_${difficulty}`));
// Add three bars
for (let i = 0; i < 3; i++) {
const bar = document.createElement("span");
bar.className = "bar";
badge.appendChild(bar);
}
wrapper.appendChild(badge);
container.appendChild(wrapper);
}
/**
* Update the level indicator
* @param {HTMLElement} element - The level indicator element

View File

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

View File

@@ -10,6 +10,8 @@ export function validateUserCode(userCode, lesson) {
return validateHtmlCode(userCode, lesson);
case "tailwind":
return validateTailwindClasses(userCode, lesson);
case "javascript":
return validateJavaScriptCode(userCode, lesson);
case "css":
default:
return validateCssCode(userCode, lesson);
@@ -204,6 +206,80 @@ function validateHtmlCode(userHtml, lesson) {
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) {
if (!lesson || !lesson.validations) {
return { isValid: true, message: "No validations specified for this lesson." };

View File

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

View File

@@ -1,18 +1,20 @@
/**
* CodeEditor - CodeMirror 6 wrapper with Emmet support
*/
import { EditorState, Prec } from "@codemirror/state";
import { EditorView, keymap, placeholder } from "@codemirror/view";
import { EditorState, EditorSelection, Prec, StateField, Compartment } from "@codemirror/state";
import { EditorView, keymap, placeholder, Decoration } from "@codemirror/view";
import { defaultKeymap, historyKeymap, indentMore, indentLess, undo, redo } from "@codemirror/commands";
import { history } from "@codemirror/commands";
import { html } from "@codemirror/lang-html";
import { css } from "@codemirror/lang-css";
import { markdown } from "@codemirror/lang-markdown";
import { javascript } from "@codemirror/lang-javascript";
import { autocompletion } from "@codemirror/autocomplete";
import { abbreviationTracker, expandAbbreviation } from "@emmetio/codemirror6-plugin";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { tags } from "@lezer/highlight";
// Custom theme with purple accent colors (matching app completed state)
// Custom theme with pink accent colors (matching app completed state)
const crispyTheme = EditorView.theme(
{
"&": {
@@ -20,10 +22,10 @@ const crispyTheme = EditorView.theme(
color: "#c8c8d0"
},
".cm-content": {
caretColor: "#9b6dd4"
caretColor: "#d46d9b"
},
".cm-cursor, .cm-dropCursor": {
borderLeftColor: "#9b6dd4"
borderLeftColor: "#d46d9b"
},
"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
backgroundColor: "#3e3e4a"
@@ -34,10 +36,10 @@ const crispyTheme = EditorView.theme(
},
".cm-searchMatch": {
backgroundColor: "#3e3e4a",
outline: "1px solid #9b6dd4"
outline: "1px solid #d46d9b"
},
".cm-searchMatch.cm-searchMatch-selected": {
backgroundColor: "rgba(155, 109, 212, 0.3)"
backgroundColor: "rgba(212, 109, 155, 0.3)"
},
".cm-activeLine": {
backgroundColor: "#2e2e3a"
@@ -62,13 +64,13 @@ const crispyTheme = EditorView.theme(
// Default syntax highlighting (blue accent)
const defaultHighlight = HighlightStyle.define([
{ tag: tags.keyword, color: "#c9a6eb" },
{ tag: tags.keyword, color: "#eba6c9" },
{ tag: tags.operator, color: "#cdd6f4" },
{ tag: tags.variableName, color: "#89b4fa" },
{ tag: tags.propertyName, color: "#89b4fa" },
{ tag: tags.attributeName, color: "#89b4fa" },
{ tag: tags.className, color: "#89b4fa" },
{ tag: tags.tagName, color: "#c9a6eb" },
{ tag: tags.tagName, color: "#eba6c9" },
{ tag: tags.string, color: "#a6e3a1" },
{ tag: tags.number, color: "#fab387" },
{ tag: tags.bool, color: "#fab387" },
@@ -78,20 +80,20 @@ const defaultHighlight = HighlightStyle.define([
{ tag: tags.punctuation, color: "#cdd6f4" },
{ tag: tags.definition(tags.variableName), color: "#89b4fa" },
{ tag: tags.function(tags.variableName), color: "#89b4fa" },
{ tag: tags.atom, color: "#c9a6eb" },
{ tag: tags.atom, color: "#eba6c9" },
{ tag: tags.unit, color: "#a6e3a1" },
{ tag: tags.color, color: "#f9e2af" }
]);
// CSS section highlighting (purple selectors)
// CSS section highlighting (pink selectors)
const cssHighlight = HighlightStyle.define([
{ tag: tags.keyword, color: "#c9a6eb" },
{ tag: tags.keyword, color: "#eba6c9" },
{ tag: tags.operator, color: "#cdd6f4" },
{ tag: tags.variableName, color: "#c9a6eb" },
{ tag: tags.variableName, color: "#eba6c9" },
{ tag: tags.propertyName, color: "#89b4fa" },
{ tag: tags.attributeName, color: "#89b4fa" },
{ tag: tags.className, color: "#c9a6eb" },
{ tag: tags.tagName, color: "#c9a6eb" },
{ tag: tags.className, color: "#eba6c9" },
{ tag: tags.tagName, color: "#eba6c9" },
{ tag: tags.string, color: "#a6e3a1" },
{ tag: tags.number, color: "#fab387" },
{ tag: tags.bool, color: "#fab387" },
@@ -99,9 +101,9 @@ const cssHighlight = HighlightStyle.define([
{ tag: tags.comment, color: "#6c7086", fontStyle: "italic" },
{ tag: tags.bracket, color: "#cdd6f4" },
{ tag: tags.punctuation, color: "#cdd6f4" },
{ tag: tags.definition(tags.variableName), color: "#c9a6eb" },
{ tag: tags.definition(tags.variableName), color: "#eba6c9" },
{ tag: tags.function(tags.variableName), color: "#89b4fa" },
{ tag: tags.atom, color: "#c9a6eb" },
{ tag: tags.atom, color: "#eba6c9" },
{ tag: tags.unit, color: "#a6e3a1" },
{ tag: tags.color, color: "#f9e2af" }
]);
@@ -146,17 +148,135 @@ export class CodeEditor {
this.mode = options.mode || "css";
this.section = options.section || null;
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 = "") {
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
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
const langExtension = this.mode === "html" ? html() : css();
const langExtension =
this.mode === "html" ? html() : this.mode === "javascript" ? javascript() : this.mode === "markdown" ? markdown() : 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
const extensions = [
@@ -165,6 +285,10 @@ export class CodeEditor {
editorTheme,
// History for undo/redo
history(),
// Read-only zones (decorations, change filter, and cursor constraint)
readOnlyDecorations,
readOnlyFilter,
cursorFilter,
// Emmet abbreviation tracking
abbreviationTracker(),
// High priority keymap for Emmet
@@ -184,20 +308,21 @@ export class CodeEditor {
}),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
this.onChange(this.getValue());
// Report only the editable portion to the onChange handler
this.onChange(this.getEditableValue());
}
}),
EditorView.lineWrapping
];
// Add placeholder if provided
if (this.options.placeholder) {
// Add placeholder if provided (only makes sense when no prefix/suffix)
if (this.options.placeholder && this.prefixLength === 0 && this.suffixLength === 0) {
extensions.push(placeholder(this.options.placeholder));
}
// Create editor state
const state = EditorState.create({
doc: initialValue,
doc: fullDoc,
extensions
});
@@ -207,26 +332,47 @@ export class CodeEditor {
parent: this.container
});
// Position cursor at start of editable area
if (this.prefixLength > 0) {
this.view.dispatch({
selection: { anchor: this.prefixLength }
});
}
return this;
}
/**
* Get current editor value
* Get current full editor value (including prefix/suffix)
*/
getValue() {
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) {
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({
changes: {
from: 0,
to: this.view.state.doc.length,
from: editableStart,
to: editableEnd,
insert: value
}
});
@@ -234,9 +380,12 @@ export class CodeEditor {
/**
* 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) {
this.init(value);
setValueAndClearHistory(value, prefix = "", suffix = "") {
this.initWithContext(prefix, value, suffix);
}
/**
@@ -246,8 +395,8 @@ export class CodeEditor {
if (this.mode === mode) return;
this.mode = mode;
const currentValue = this.getValue();
this.init(currentValue);
const editableValue = this.getEditableValue();
this.initWithContext(this.currentPrefix, editableValue, this.currentSuffix);
}
/**
@@ -257,8 +406,8 @@ export class CodeEditor {
if (this.section === section) return;
this.section = section;
const currentValue = this.getValue();
this.init(currentValue);
const editableValue = this.getEditableValue();
this.initWithContext(this.currentPrefix, editableValue, this.currentSuffix);
}
/**

View File

@@ -3,6 +3,7 @@
* Single source of truth for lesson state and progress
*/
import { validateUserCode } from "../helpers/validator.js";
import { marked } from "marked";
// Auth sync - lazy loaded to avoid circular dependencies
let authModule = null;
@@ -215,18 +216,18 @@ export class LessonEngine {
iframe.style.height = "100%";
iframe.style.border = "none";
iframe.title = "Preview";
iframe.setAttribute("sandbox", "allow-scripts");
const container = document.getElementById(previewContainer || "preview-area");
container.innerHTML = "";
container.appendChild(iframe);
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
iframeDoc.open();
let html;
if (mode === "html" || mode === "playground") {
// For HTML/playground mode, user code IS the HTML content (may include <style> blocks)
const userHtml = this.userCode || "";
iframeDoc.write(`
html = `
<!DOCTYPE html>
<html>
<head>
@@ -238,11 +239,11 @@ export class LessonEngine {
${userHtml}
</body>
</html>
`);
`;
} else if (mode === "tailwind") {
// For Tailwind mode, user code goes directly in HTML classes
const htmlWithClasses = this.injectTailwindClasses(previewHTML, this.userCode);
iframeDoc.write(`
html = `
<!DOCTYPE html>
<html>
<head>
@@ -255,11 +256,67 @@ export class LessonEngine {
${htmlWithClasses}
</body>
</html>
`);
`;
} else if (mode === "javascript") {
// For JavaScript mode, user code runs as a script against previewHTML
const { codePrefix, codeSuffix } = this.currentLesson;
const fullScript = `${codePrefix || ""}${this.userCode || ""}${codeSuffix || ""}`;
html = `
<!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) {
document.body.innerHTML += '<pre style="color:red">' + e.message + '</pre>';
}
</script>
</body>
</html>
`;
} else if (mode === "markdown") {
// For Markdown mode, parse user code to HTML
const renderedHtml = marked.parse(this.userCode || "");
html = `
<!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 {
// Original CSS mode
const userCssWithWrapper = this.getCompleteCss();
iframeDoc.write(`
html = `
<!DOCTYPE html>
<html>
<head>
@@ -272,10 +329,10 @@ export class LessonEngine {
${previewHTML}
</body>
</html>
`);
`;
}
iframeDoc.close();
iframe.srcdoc = html;
}
injectTailwindClasses(html, userClasses) {
@@ -308,6 +365,7 @@ export class LessonEngine {
iframe.style.height = "100%";
iframe.style.border = "none";
iframe.title = "Expected Result";
iframe.setAttribute("sandbox", "allow-scripts");
const container = document.getElementById("preview-expected");
if (!container) return;
@@ -315,12 +373,11 @@ export class LessonEngine {
container.innerHTML = "";
container.appendChild(iframe);
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
iframeDoc.open();
let html;
if (mode === "html" || mode === "playground") {
// For HTML/playground mode, solution code IS the HTML content
iframeDoc.write(`
html = `
<!DOCTYPE html>
<html>
<head>
@@ -332,11 +389,11 @@ export class LessonEngine {
${solutionCode}
</body>
</html>
`);
`;
} else if (mode === "tailwind") {
// For Tailwind mode, inject solution classes into HTML
const htmlWithClasses = this.injectTailwindClasses(previewHTML, solutionCode);
iframeDoc.write(`
html = `
<!DOCTYPE html>
<html>
<head>
@@ -349,12 +406,68 @@ export class LessonEngine {
${htmlWithClasses}
</body>
</html>
`);
`;
} else if (mode === "javascript") {
// For JavaScript mode, solution code runs as a script against previewHTML
const { codePrefix, codeSuffix } = this.currentLesson;
const fullScript = `${codePrefix || ""}${solutionCode}${codeSuffix || ""}`;
html = `
<!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) {
document.body.innerHTML += '<pre style="color:red">' + e.message + '</pre>';
}
</script>
</body>
</html>
`;
} else if (mode === "markdown") {
// For Markdown mode, parse solution to HTML
const renderedHtml = marked.parse(solutionCode || "");
html = `
<!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 {
// CSS mode - wrap solution with prefix/suffix
const { codePrefix, codeSuffix } = this.currentLesson;
const solutionCss = `${codePrefix || ""}${solutionCode}${codeSuffix || ""}`;
iframeDoc.write(`
html = `
<!DOCTYPE html>
<html>
<head>
@@ -367,10 +480,10 @@ export class LessonEngine {
${previewHTML}
</body>
</html>
`);
`;
}
iframeDoc.close();
iframe.srcdoc = html;
}
/**

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" href="./favicon.ico" type="image/x-icon" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' https://librete.ch https://liberapay.com; style-src 'self' 'unsafe-inline'; connect-src 'self' https://*.supabase.co wss://*.supabase.co; img-src 'self' https://liberapay.com data:; font-src 'self'; frame-src 'self' blob:" />
<!-- Primary Meta Tags -->
<title>CODE CRISPIES - Learn HTML & CSS Interactively | Free Coding Practice</title>
@@ -74,7 +75,9 @@
<nav class="main-nav" id="main-nav" aria-label="Main sections">
<a href="#css" class="nav-link" data-section="css">CSS</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">JavaScript</a>
<a href="#reference/css" class="nav-link nav-link-ref" data-section="reference">Reference</a>
</nav>
<button id="auth-trigger-header" class="btn btn-outline btn-sm" data-i18n="authLogin">Log In</button>
@@ -162,13 +165,30 @@
<p data-i18n="landingHtmlDesc">Semantic markup and native elements</p>
<span class="section-card-progress" id="html-progress"></span>
</a>
<!-- Tailwind temporarily disabled
<a href="#tailwind" class="section-card" data-section="tailwind">
<div class="section-card-icon" style="background: #26a69a">TW</div>
<h3>Tailwind CSS</h3>
<p data-i18n="landingTailwindDesc">Utility-first CSS framework</p>
<span class="section-card-progress" id="tailwind-progress"></span>
</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: #f0db4f; color: #333">JS</div>
<h3>JavaScript</h3>
<p data-i18n="landingJsDesc">Interactive scripting for web pages</p>
<span class="section-card-progress" id="javascript-progress"></span>
</a>
</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 class="coming-soon">
@@ -181,13 +201,6 @@
<h3 data-i18n="comingSoonAchievementsTitle">Achievements</h3>
<p data-i18n="comingSoonAchievementsText">Earn badges as you master new skills. Track your learning milestones.</p>
</article>
<article class="coming-soon-card">
<span class="coming-soon-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg>
</span>
<h3 data-i18n="comingSoonJsTitle">JavaScript</h3>
<p data-i18n="comingSoonJsText">Interactive JavaScript lessons with live code execution and DOM manipulation.</p>
</article>
<article class="coming-soon-card">
<span class="coming-soon-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
@@ -214,12 +227,6 @@
</div>
</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">
<h2 data-i18n="landingCtaTitle">Start Learning Today</h2>
<a href="#welcome/0" class="cta-button" data-i18n="landingCtaButton">Let's get crispy!</a>
@@ -255,7 +262,7 @@
</section>
</div>
<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">
<button type="button" class="btn-text privacy-link" data-i18n="footerPrivacy">Privacy Policy</button>
<span class="footer-separator">·</span>
@@ -312,7 +319,7 @@
</section>
</div>
<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">
<button type="button" class="btn-text privacy-link" data-i18n="footerPrivacy">Privacy Policy</button>
<span class="footer-separator">·</span>
@@ -331,6 +338,7 @@
<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/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>
<div class="reference-body" id="reference-body">
<!-- Reference content injected by JS -->
@@ -365,7 +373,7 @@
</section>
</div>
<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">
<button type="button" class="btn-text privacy-link" data-i18n="footerPrivacy">Privacy Policy</button>
<span class="footer-separator">·</span>
@@ -466,6 +474,14 @@
<button id="close-sidebar" class="close-btn" data-i18n-aria-label="closeMenu" aria-label="Close menu">&times;</button>
</div>
<nav class="sidebar-section sidebar-nav-mobile" aria-label="Learning paths">
<a href="#css" class="sidebar-nav-link" data-section="css">CSS</a>
<a href="#html" class="sidebar-nav-link" data-section="html">HTML</a>
<!-- <a href="#tailwind" class="sidebar-nav-link" data-section="tailwind">Tailwind</a> -->
<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">
<h4 data-i18n="progress">Progress</h4>
<div class="progress-display milestone-progress" id="progress-display">
@@ -479,10 +495,13 @@
<span class="milestone" data-value="75">75</span>
<span class="milestone" data-value="100">100</span>
</div>
<div class="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
<div class="progress-bar-row">
<div class="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
</div>
<span class="progress-current" id="progress-current">0/1</span>
</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>
@@ -504,23 +523,27 @@
<div class="sidebar-section">
<h4 data-i18n="settings">Settings</h4>
<label class="setting-row">
<span class="setting-label" data-i18n="language">Language</span>
<select id="lang-select" class="lang-select">
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="pl">Polski</option>
<option value="es">Español</option>
<option value="ar">العربية</option>
<option value="uk">Українська</option>
</select>
</label>
<label class="toggle-switch">
<input type="checkbox" id="disable-feedback-toggle" checked />
<span class="toggle-slider"></span>
<span class="toggle-label" data-i18n="showHints">Show Hints</span>
</label>
<button id="reset-btn" class="btn btn-text" data-i18n="resetAllProgress">Reset All Progress</button>
<div class="settings-card">
<label class="settings-row">
<span class="settings-label" data-i18n="language">Language</span>
<select id="lang-select" class="lang-select">
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="pl">Polski</option>
<option value="es">Español</option>
<option value="ar">العربية</option>
<option value="uk">Українська</option>
</select>
</label>
<label class="settings-row">
<span class="settings-label" data-i18n="showHints">Show Hints</span>
<input type="checkbox" id="disable-feedback-toggle" class="settings-toggle" checked />
</label>
<div class="settings-row">
<span class="settings-label" data-i18n="resetAllProgress">Reset All Progress</span>
<button id="reset-btn" class="btn btn-sm btn-ghost" data-i18n="reset">Reset</button>
</div>
</div>
</div>
<footer class="app-footer">

View File

@@ -283,6 +283,22 @@ kbd {
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: #d4a017;
}
[data-section="javascript"] .logo h1 .crispies-text {
background: #d4a017;
}
.help-toggle {
width: 28px;
height: 28px;
@@ -308,6 +324,16 @@ kbd {
gap: var(--spacing-sm);
}
#auth-trigger-header {
display: none;
}
@media (min-width: 769px) {
#auth-trigger-header {
display: inline-flex;
}
}
/* ================= GAME LAYOUT ================= */
.game-layout {
display: flex;
@@ -374,6 +400,7 @@ kbd {
gap: 0.5rem;
background: var(--primary-bg-medium);
color: var(--primary-color);
min-width: 0;
padding: 4px 12px;
border-radius: 16px;
font-size: 0.8rem;
@@ -385,12 +412,18 @@ kbd {
.module-name {
text-transform: uppercase;
letter-spacing: 0.5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.module-pill .level-indicator {
color: var(--primary-dark);
font-weight: 500;
opacity: 0.8;
white-space: nowrap;
flex-shrink: 0;
}
.lesson-title-row {
@@ -398,7 +431,13 @@ kbd {
align-items: center;
gap: 0.5rem;
margin-bottom: var(--spacing-sm);
flex-wrap: wrap;
}
.lesson-title-row .difficulty-wrapper {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
#lesson-title {
@@ -447,6 +486,28 @@ kbd {
vertical-align: middle;
}
.difficulty-badge {
display: inline-flex;
align-items: flex-end;
gap: 2px;
vertical-align: middle;
}
.difficulty-badge .bar {
width: 3px;
border-radius: 1px;
background: var(--border-color);
}
.difficulty-badge .bar:nth-child(1) { height: 6px; }
.difficulty-badge .bar:nth-child(2) { height: 9px; }
.difficulty-badge .bar:nth-child(3) { height: 12px; }
.difficulty-easy .bar:nth-child(1),
.difficulty-medium .bar:nth-child(1),
.difficulty-medium .bar:nth-child(2),
.difficulty-hard .bar { background: var(--light-text); }
.lesson-description {
font-size: 0.95rem;
line-height: 1.6;
@@ -548,6 +609,12 @@ kbd {
height: 100%;
}
/* Read-only zones (codePrefix/codeSuffix) */
.cm-readonly-zone {
opacity: 0.5;
background: rgba(100, 100, 120, 0.1);
}
.editor-content .cm-scroller {
overflow: auto;
}
@@ -893,8 +960,9 @@ kbd {
/* ================= GAME CONTROLS ================= */
.game-controls {
display: flex;
justify-content: space-between;
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: var(--spacing-md);
align-items: center;
padding: var(--spacing-md);
background: var(--panel-bg);
@@ -902,8 +970,16 @@ kbd {
box-shadow: 0 10px 20px -10px rgba(0, 0, 0, 0.08);
}
.game-controls.centered {
justify-content: center;
.game-controls #prev-btn {
justify-self: start;
}
.game-controls .module-pill {
justify-self: center;
}
.game-controls #next-btn {
justify-self: end;
}
/* ================= SIDEBAR ================= */
@@ -987,14 +1063,69 @@ kbd {
border-bottom: none;
}
/* Mobile navigation in sidebar */
.sidebar-nav-mobile {
display: flex;
flex-direction: column;
gap: 0;
}
.sidebar-nav-link {
display: block;
padding: 0.6rem var(--spacing-md);
font-size: 0.9rem;
font-weight: 500;
text-decoration: none;
color: var(--text-color);
border-bottom: 1px solid var(--border-color);
}
.sidebar-nav-link:last-child {
border-bottom: none;
}
.sidebar-nav-link:hover {
background: var(--primary-bg-light);
color: var(--primary-color);
}
.sidebar-auth-link {
width: calc(100% - 2 * var(--spacing-md));
margin: var(--spacing-sm) var(--spacing-md);
padding: 0.5rem 1rem;
text-align: center;
background: var(--primary-color);
color: var(--white-text);
border: none;
border-radius: var(--border-radius-md);
cursor: pointer;
font-family: inherit;
font-weight: 500;
}
.sidebar-auth-link:hover {
background: var(--primary-dark);
}
@media (min-width: 769px) {
.sidebar-nav-mobile {
display: none;
}
}
/* Make the lessons nav section fill available space */
nav.sidebar-section {
nav.sidebar-section:not(.sidebar-nav-mobile) {
flex: 1;
overflow-y: auto;
min-height: 0;
padding-bottom: var(--spacing-md);
}
.sidebar-nav-mobile {
flex: none;
padding: 0;
}
.sidebar-section h4 {
font-size: 0.75rem;
text-transform: uppercase;
@@ -1031,6 +1162,28 @@ nav.sidebar-section {
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 {
gap: var(--spacing-sm);
@@ -1060,15 +1213,16 @@ nav.sidebar-section {
color: white;
}
/* Each milestone gets a portion of the gradient based on position */
.milestone.reached:nth-child(1) { background: #9163b8; }
.milestone.reached:nth-child(2) { background: linear-gradient(135deg, #9163b8, #a85dac); }
.milestone.reached:nth-child(3) { background: linear-gradient(135deg, #9163b8, #d45aa0); }
.milestone.reached:nth-child(4) { background: linear-gradient(135deg, #9163b8, #d45aa0, #e87aac); }
.milestone.reached:nth-child(5) { background: linear-gradient(135deg, #9163b8, #d45aa0, #1aafb8); }
.milestone.reached:nth-child(6) { background: linear-gradient(135deg, #9163b8, #d45aa0, #1aafb8, #4b8ecc); }
.milestone.reached:nth-child(7) { background: linear-gradient(135deg, #9163b8, #d45aa0, #1aafb8, #7c4dff); }
.milestone.reached:nth-child(8) { background: linear-gradient(135deg, #9163b8, #d45aa0, #1aafb8, #7c4dff); }
/* Each milestone gets a color evenly distributed across the gradient
Gradient: #9163b8 (0%) → #d45aa0 (33%) → #1aafb8 (67%) → #7c4dff (100%) */
.milestone.reached:nth-child(1) { background: #a55eac; } /* ~14% */
.milestone.reached:nth-child(2) { background: #c459a2; } /* ~28% */
.milestone.reached:nth-child(3) { background: #d45aa0; } /* ~33% pink */
.milestone.reached:nth-child(4) { background: #a874a8; } /* ~43% */
.milestone.reached:nth-child(5) { background: #7785ac; } /* ~50% */
.milestone.reached:nth-child(6) { background: #33a3b6; } /* ~62% */
.milestone.reached:nth-child(7) { background: #4889d8; } /* ~80% */
.milestone.reached:nth-child(8) { background: #7c4dff; } /* 100% */
.milestone.current {
color: white;
@@ -1098,6 +1252,22 @@ nav.sidebar-section {
animation: milestone-pop 0.5s ease-out;
}
/* Sidebar section grouping headers */
.sidebar-section-header {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--light-text);
padding: 0.75rem 0.75rem 0.25rem;
margin: 0.5rem 0 0;
border-left: 3px solid transparent;
}
.sidebar-section-header:first-child {
margin-top: 0;
}
/* Module List in Sidebar */
.module-list {
/* No max-height - parent nav.sidebar-section handles overflow */
@@ -1357,8 +1527,63 @@ button.lesson-list-item {
display: none;
}
/* ================= TOGGLE SWITCH ================= */
/* Setting row (for label + control) */
/* ================= SETTINGS CARD ================= */
.settings-card {
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-md);
overflow: hidden;
}
.settings-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.settings-row:last-child {
border-bottom: none;
}
.settings-label {
font-size: 0.9rem;
color: var(--text-color);
}
.settings-toggle {
width: 40px;
height: 22px;
appearance: none;
background: var(--border-color);
border-radius: 11px;
position: relative;
cursor: pointer;
transition: background 0.2s;
}
.settings-toggle::before {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
background: white;
border-radius: 50%;
transition: transform 0.2s;
}
.settings-toggle:checked {
background: var(--primary-color);
}
.settings-toggle:checked::before {
transform: translateX(18px);
}
/* Legacy setting row (for label + control) */
.setting-row {
display: flex;
align-items: center;
@@ -2388,17 +2613,11 @@ input:checked + .toggle-slider::before {
.device-notice {
margin-top: var(--spacing-lg);
text-align: center;
padding: 1rem;
}
.device-notice p {
display: inline-block;
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));
border-radius: var(--border-radius-md);
color: var(--light-text);
font-size: 0.9rem;
margin: 0;
}
.device-notice strong {
@@ -2645,7 +2864,7 @@ input:checked + .toggle-slider::before {
}
.section-overview code {
background: rgba(var(--section-color-rgb, 145, 99, 184), 0.1);
background: rgba(var(--section-color-rgb, 217, 90, 138), 0.1);
color: var(--section-color-dark, var(--primary-dark));
padding: 0.1rem 0.35rem;
border-radius: 4px;
@@ -2755,7 +2974,7 @@ input:checked + .toggle-slider::before {
/* Inline code in topic text */
.topic-text code {
background: rgba(var(--section-color-rgb, 145, 99, 184), 0.1);
background: rgba(var(--section-color-rgb, 217, 90, 138), 0.1);
color: var(--section-color-dark, var(--primary-dark));
padding: 0.15rem 0.4rem;
border-radius: 4px;
@@ -3152,11 +3371,16 @@ input:checked + .toggle-slider::before {
.module-pill {
flex: 1;
min-width: 0;
max-width: 100%;
margin: 0 var(--spacing-sm);
justify-content: center;
overflow: hidden;
}
.module-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -3392,7 +3616,7 @@ input:checked + .toggle-slider::before {
}
/* ================= SECTION COLOR CODING ================= */
/* CSS Section uses default purple from :root */
/* CSS Section uses default pink from :root */
/* HTML Section - Pink (balanced) */
[data-section="html"] {
@@ -3410,6 +3634,22 @@ input:checked + .toggle-slider::before {
--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: #d4a017;
--section-color-light: #e0b840;
--section-color-dark: #b08610;
--section-color-rgb: 212, 160, 23;
}
/* Apply section colors to nav links */
.nav-link[data-section="css"] {
color: #9163b8;
@@ -3423,6 +3663,14 @@ input:checked + .toggle-slider::before {
color: #1aafb8;
}
.nav-link[data-section="markdown"] {
color: #5b8dd9;
}
.nav-link[data-section="javascript"] {
color: #d4a017;
}
.nav-link[data-section="css"]:hover,
.nav-link[data-section="css"].active {
background: rgba(145, 99, 184, 0.1);
@@ -3441,6 +3689,18 @@ input:checked + .toggle-slider::before {
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, 23, 0.1);
color: #b08610;
}
/* Hint section colors */
body[data-section="css"] .hint {
background: rgba(145, 99, 184, 0.3);
@@ -3469,6 +3729,24 @@ body[data-section="tailwind"] .hint-progress {
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, 23, 0.3);
border-left-color: #e0b840;
}
body[data-section="javascript"] .hint-progress {
background: #d4a017;
}
/* RTL hint border */
[dir="rtl"] body[data-section="css"] .hint {
border-right-color: #a98cd6;
@@ -3482,6 +3760,14 @@ body[data-section="tailwind"] .hint-progress {
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 */
.ref-nav-link[data-ref="css"],
.ref-nav-link[data-ref="selectors"],
@@ -3567,6 +3853,42 @@ body[data-section="tailwind"] .cm-editor .cm-activeLine {
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: #d4a017 !important;
}
body[data-section="javascript"] .cm-editor .cm-cursor,
body[data-section="javascript"] .cm-editor .cm-dropCursor {
border-left-color: #d4a017 !important;
}
body[data-section="javascript"] .cm-editor .cm-selectionBackground,
body[data-section="javascript"] .cm-editor .cm-content ::selection {
background-color: rgba(212, 160, 23, 0.25) !important;
}
body[data-section="javascript"] .cm-editor .cm-activeLine {
background-color: rgba(212, 160, 23, 0.08) !important;
}
/* Module pill section colors */
body[data-section="css"] .module-pill {
background: rgba(145, 99, 184, 0.1);
@@ -3595,6 +3917,24 @@ body[data-section="tailwind"] .module-pill .level-indicator {
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, 23, 0.1);
color: #d4a017;
}
body[data-section="javascript"] .module-pill .level-indicator {
color: #b08610;
}
/* Code block border section colors */
body[data-section="css"] .code-block {
border-color: rgba(145, 99, 184, 0.4);
@@ -3608,6 +3948,14 @@ body[data-section="tailwind"] .code-block {
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, 23, 0.4);
}
/* Section code block CodeMirror syntax highlighting overrides */
body[data-section="css"] .code-block .cm-editor .cm-line {
color: #c9c0e0;
@@ -3621,6 +3969,14 @@ body[data-section="tailwind"] .code-block .cm-editor .cm-line {
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: #e0d8b0;
}
/* Task instruction bubble section colors */
[data-section="css"] .task-instruction {
background: rgba(145, 99, 184, 0.92);
@@ -3634,6 +3990,14 @@ body[data-section="tailwind"] .code-block .cm-editor .cm-line {
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, 23, 0.92);
}
/* Section page progress bar colors */
body[data-section="css"] .section-progress-bar .progress-fill {
background: #9163b8;
@@ -3647,6 +4011,14 @@ body[data-section="tailwind"] .section-progress-bar .progress-fill {
background: #1aafb8;
}
body[data-section="markdown"] .section-progress-bar .progress-fill {
background: #5b8dd9;
}
body[data-section="javascript"] .section-progress-bar .progress-fill {
background: #d4a017;
}
/* Section page header colors */
[data-section="css"] .section-hero h1 {
color: #9163b8;
@@ -3660,6 +4032,14 @@ body[data-section="tailwind"] .section-progress-bar .progress-fill {
color: #1aafb8;
}
[data-section="markdown"] .section-hero h1 {
color: #5b8dd9;
}
[data-section="javascript"] .section-hero h1 {
color: #d4a017;
}
/* Lesson title h2 section colors */
body[data-section="css"] #lesson-title {
color: #9163b8;
@@ -3673,6 +4053,14 @@ body[data-section="tailwind"] #lesson-title {
color: #1aafb8;
}
body[data-section="markdown"] #lesson-title {
color: #5b8dd9;
}
body[data-section="javascript"] #lesson-title {
color: #d4a017;
}
/* Section and Reference footer - override landing-footer styles */
.section-footer.landing-footer,
.reference-footer.landing-footer {

View File

@@ -19,6 +19,10 @@ describe("Lessons Config Module", () => {
expect(moduleIds).toContain("css-basic-selectors");
expect(moduleIds).toContain("box-model");
expect(moduleIds).toContain("flexbox");
// JavaScript modules
expect(moduleIds).toContain("js-variables");
expect(moduleIds).toContain("js-dom");
expect(moduleIds).toContain("js-events");
});
test("should have mode set on each lesson", async () => {
@@ -27,7 +31,7 @@ describe("Lessons Config Module", () => {
modules.forEach((module) => {
module.lessons.forEach((lesson) => {
expect(lesson.mode).toBeDefined();
expect(["html", "css", "tailwind", "playground"]).toContain(lesson.mode);
expect(["html", "css", "tailwind", "markdown", "javascript", "playground"]).toContain(lesson.mode);
});
});
});

View File

@@ -0,0 +1,538 @@
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
import {
renderModuleList,
renderLesson,
renderLevelIndicator,
renderDifficultyBadge,
showFeedback,
clearFeedback,
updateActiveLessonInSidebar,
computeLessonDifficulty
} from "../../src/helpers/renderer.js";
// Mock i18n
vi.mock("../../src/i18n.js", () => ({
t: (key, params = {}) => {
const translations = {
lessonLabel: "Lesson",
untitledLesson: "Untitled Lesson",
lessonFallback: `Lesson ${params.index || ""}`,
difficulty_easy_label: "Easy difficulty",
difficulty_medium_label: "Medium difficulty",
difficulty_hard_label: "Hard difficulty",
difficulty_easy: "Easy",
difficulty_medium: "Medium",
difficulty_hard: "Hard"
};
return translations[key] || key;
}
}));
describe("Renderer Extended Coverage", () => {
beforeEach(() => {
document.body.innerHTML = `
<div id="module-list"></div>
<div class="lesson-title-row">
<h2 id="title"></h2>
</div>
<div id="description"></div>
<div id="task"></div>
<div id="preview"></div>
<div id="prefix"></div>
<textarea id="input"></textarea>
<div id="suffix"></div>
<div id="level-indicator"></div>
<div class="editor-content"></div>
<input type="checkbox" id="disable-feedback-toggle" checked>
`;
localStorage.clear();
});
describe("renderModuleList - progress tracking", () => {
test("renderModuleList_CorruptedProgress_HandlesGracefully", () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
localStorage.setItem("codeCrispies.progress", "not-valid-json{{{");
const container = document.getElementById("module-list");
const modules = [{ id: "mod1", title: "Module 1", lessons: [{ title: "L1" }] }];
renderModuleList(container, modules, vi.fn(), vi.fn());
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Error parsing progress"), expect.anything());
// Should still render modules despite parse error
expect(container.querySelectorAll(".module-header").length).toBe(1);
errorSpy.mockRestore();
});
test("renderModuleList_CompletedModule_AddedCompletedClass", () => {
localStorage.setItem(
"codeCrispies.progress",
JSON.stringify({
mod1: { completed: [0, 1], current: 1 }
})
);
const container = document.getElementById("module-list");
const modules = [{ id: "mod1", title: "Module 1", lessons: [{ title: "L1" }, { title: "L2" }] }];
renderModuleList(container, modules, vi.fn(), vi.fn());
const header = container.querySelector(".module-header");
expect(header.classList.contains("completed")).toBe(true);
});
test("renderModuleList_PartiallyCompleted_NoCompletedClass", () => {
localStorage.setItem(
"codeCrispies.progress",
JSON.stringify({
mod1: { completed: [0], current: 1 }
})
);
const container = document.getElementById("module-list");
const modules = [{ id: "mod1", title: "Module 1", lessons: [{ title: "L1" }, { title: "L2" }] }];
renderModuleList(container, modules, vi.fn(), vi.fn());
const header = container.querySelector(".module-header");
expect(header.classList.contains("completed")).toBe(false);
});
test("renderModuleList_CompletedLesson_HasCompletedClass", () => {
localStorage.setItem(
"codeCrispies.progress",
JSON.stringify({
mod1: { completed: [0], current: 1 }
})
);
const container = document.getElementById("module-list");
const modules = [{ id: "mod1", title: "Module 1", lessons: [{ title: "L1" }, { title: "L2" }] }];
renderModuleList(container, modules, vi.fn(), vi.fn());
const lessonItems = container.querySelectorAll(".lesson-list-item");
expect(lessonItems[0].classList.contains("completed")).toBe(true);
expect(lessonItems[1].classList.contains("completed")).toBe(false);
});
test("renderModuleList_CurrentLesson_HasCurrentClass", () => {
localStorage.setItem(
"codeCrispies.progress",
JSON.stringify({
mod1: { completed: [0], current: 1 }
})
);
const container = document.getElementById("module-list");
const modules = [{ id: "mod1", title: "Module 1", lessons: [{ title: "L1" }, { title: "L2" }] }];
renderModuleList(container, modules, vi.fn(), vi.fn());
const lessonItems = container.querySelectorAll(".lesson-list-item");
expect(lessonItems[1].classList.contains("current")).toBe(true);
expect(lessonItems[0].classList.contains("current")).toBe(false);
});
});
describe("renderModuleList - welcome/playground always expanded", () => {
test("renderModuleList_WelcomeModule_AlwaysExpanded", () => {
const container = document.getElementById("module-list");
const modules = [{ id: "welcome", title: "Welcome", lessons: [{ title: "Intro" }] }];
renderModuleList(container, modules, vi.fn(), vi.fn());
const details = container.querySelector("details.module-container");
expect(details.open).toBe(true);
});
test("renderModuleList_PlaygroundModule_AlwaysExpanded", () => {
const container = document.getElementById("module-list");
const modules = [{ id: "playground", title: "Playground", lessons: [{ title: "Play" }] }];
renderModuleList(container, modules, vi.fn(), vi.fn());
const details = container.querySelector("details.module-container");
expect(details.open).toBe(true);
});
test("renderModuleList_RegularModule_CollapsedByDefault", () => {
const container = document.getElementById("module-list");
const modules = [{ id: "flexbox", title: "Flexbox", lessons: [{ title: "L1" }] }];
renderModuleList(container, modules, vi.fn(), vi.fn());
const details = container.querySelector("details.module-container");
expect(details.open).toBe(false);
});
});
describe("renderModuleList - lesson fallback title", () => {
test("renderModuleList_NoLessonTitle_UsesFallback", () => {
const container = document.getElementById("module-list");
const modules = [{ id: "mod1", title: "Module 1", lessons: [{}] }];
renderModuleList(container, modules, vi.fn(), vi.fn());
const lessonItem = container.querySelector(".lesson-list-item");
expect(lessonItem.textContent).toContain("Lesson");
});
});
describe("renderModuleList - click behavior", () => {
test("renderModuleList_LessonClick_RemovesActiveFromOthers", () => {
const container = document.getElementById("module-list");
const modules = [
{
id: "mod1",
title: "Module 1",
lessons: [{ title: "L1" }, { title: "L2" }]
}
];
const onSelectLesson = vi.fn();
renderModuleList(container, modules, vi.fn(), onSelectLesson);
const lessonItems = container.querySelectorAll(".lesson-list-item");
// Click first lesson
lessonItems[0].click();
expect(lessonItems[0].classList.contains("active")).toBe(true);
expect(onSelectLesson).toHaveBeenCalledWith("mod1", 0);
// Click second lesson
lessonItems[1].click();
expect(lessonItems[0].classList.contains("active")).toBe(false);
expect(lessonItems[1].classList.contains("active")).toBe(true);
expect(onSelectLesson).toHaveBeenCalledWith("mod1", 1);
});
});
describe("renderModuleList - module dataset", () => {
test("renderModuleList_DataAttributes_SetCorrectly", () => {
const container = document.getElementById("module-list");
const modules = [{ id: "flex-mod", title: "Flex Module", lessons: [{ title: "L1" }] }];
renderModuleList(container, modules, vi.fn(), vi.fn());
const details = container.querySelector("details.module-container");
expect(details.dataset.moduleId).toBe("flex-mod");
const header = container.querySelector(".module-header");
expect(header.dataset.moduleId).toBe("flex-mod");
const lesson = container.querySelector(".lesson-list-item");
expect(lesson.dataset.moduleId).toBe("flex-mod");
expect(lesson.dataset.lessonIndex).toBe("0");
});
});
describe("renderModuleList - empty lessons", () => {
test("renderModuleList_EmptyLessonsArray_RendersModuleOnly", () => {
const container = document.getElementById("module-list");
const modules = [{ id: "mod1", title: "Module 1", lessons: [] }];
renderModuleList(container, modules, vi.fn(), vi.fn());
expect(container.querySelectorAll(".module-header").length).toBe(1);
expect(container.querySelectorAll(".lesson-list-item").length).toBe(0);
});
});
describe("renderDifficultyBadge", () => {
test("renderDifficultyBadge_EasyLesson_CreatesEasyBadge", () => {
const container = document.querySelector(".lesson-title-row");
const lesson = { codePrefix: ".box {\n ", solution: "color: red;" };
renderDifficultyBadge(container, lesson);
const badge = container.querySelector(".difficulty-badge");
expect(badge).not.toBeNull();
expect(badge.classList.contains("difficulty-easy")).toBe(true);
expect(badge.querySelectorAll(".bar").length).toBe(3);
});
test("renderDifficultyBadge_MediumLesson_CreatesMediumBadge", () => {
const container = document.querySelector(".lesson-title-row");
const lesson = { codePrefix: "", solution: "p {\n color: red;\n}" };
renderDifficultyBadge(container, lesson);
const badge = container.querySelector(".difficulty-badge");
expect(badge.classList.contains("difficulty-medium")).toBe(true);
});
test("renderDifficultyBadge_HardLesson_CreatesHardBadge", () => {
const container = document.querySelector(".lesson-title-row");
const lesson = { codePrefix: "", solution: ".nav a {\n color: white;\n}" };
renderDifficultyBadge(container, lesson);
const badge = container.querySelector(".difficulty-badge");
expect(badge.classList.contains("difficulty-hard")).toBe(true);
});
test("renderDifficultyBadge_CalledTwice_RemovesPreviousBadge", () => {
const container = document.querySelector(".lesson-title-row");
const lesson1 = { codePrefix: ".box {\n ", solution: "color: red;" };
const lesson2 = { codePrefix: "", solution: ".nav a {\n color: white;\n}" };
renderDifficultyBadge(container, lesson1);
expect(container.querySelectorAll(".difficulty-wrapper").length).toBe(1);
renderDifficultyBadge(container, lesson2);
expect(container.querySelectorAll(".difficulty-wrapper").length).toBe(1);
const badge = container.querySelector(".difficulty-badge");
expect(badge.classList.contains("difficulty-hard")).toBe(true);
});
test("renderDifficultyBadge_HasAriaLabel", () => {
const container = document.querySelector(".lesson-title-row");
const lesson = { codePrefix: ".box {", solution: "color: red;" };
renderDifficultyBadge(container, lesson);
const badge = container.querySelector(".difficulty-badge");
expect(badge.getAttribute("aria-label")).toBeTruthy();
expect(badge.getAttribute("title")).toBeTruthy();
});
});
describe("showFeedback", () => {
test("showFeedback_Success_CreatesSuccessElement", () => {
showFeedback(true, "Great job!");
const feedback = document.querySelector(".feedback-success");
expect(feedback).not.toBeNull();
expect(feedback.innerHTML).toBe("Great job!");
});
test("showFeedback_Success_InsertedAfterEditorContent", () => {
showFeedback(true, "Good!");
const editorContent = document.querySelector(".editor-content");
const feedback = editorContent.nextSibling;
expect(feedback).not.toBeNull();
expect(feedback.classList.contains("feedback-success")).toBe(true);
});
test("showFeedback_Error_ToggleChecked_ShowsError", () => {
const toggle = document.getElementById("disable-feedback-toggle");
toggle.checked = true;
showFeedback(false, "Try again");
const feedback = document.querySelector(".feedback-error");
expect(feedback).not.toBeNull();
expect(feedback.innerHTML).toBe("Try again");
});
test("showFeedback_Error_ToggleUnchecked_HidesError", () => {
const toggle = document.getElementById("disable-feedback-toggle");
toggle.checked = false;
showFeedback(false, "Try again");
const feedback = document.querySelector(".feedback-error");
expect(feedback).toBeNull();
});
test("showFeedback_Error_AutoClearsAfterTimeout", () => {
vi.useFakeTimers();
const toggle = document.getElementById("disable-feedback-toggle");
toggle.checked = true;
showFeedback(false, "Error!");
expect(document.querySelector(".feedback-error")).not.toBeNull();
vi.advanceTimersByTime(3000);
expect(document.querySelector(".feedback-error")).toBeNull();
vi.useRealTimers();
});
test("showFeedback_Success_DoesNotAutoCleanup", () => {
vi.useFakeTimers();
showFeedback(true, "Good!");
vi.advanceTimersByTime(5000);
expect(document.querySelector(".feedback-success")).not.toBeNull();
vi.useRealTimers();
});
test("showFeedback_CalledTwice_ClearsPrevious", () => {
showFeedback(true, "First");
showFeedback(true, "Second");
const feedbacks = document.querySelectorAll(".feedback-success");
expect(feedbacks.length).toBe(1);
expect(feedbacks[0].innerHTML).toBe("Second");
});
});
describe("clearFeedback", () => {
test("clearFeedback_NoExistingFeedback_DoesNotThrow", () => {
expect(() => clearFeedback()).not.toThrow();
});
test("clearFeedback_ExistingFeedback_RemovesIt", () => {
showFeedback(true, "Test");
expect(document.querySelector(".feedback-success")).not.toBeNull();
clearFeedback();
expect(document.querySelector(".feedback-success")).toBeNull();
});
test("clearFeedback_CalledMultipleTimes_Safe", () => {
showFeedback(true, "Test");
clearFeedback();
clearFeedback();
clearFeedback();
expect(document.querySelector(".feedback-success")).toBeNull();
});
test("clearFeedback_ClearsTimeout", () => {
vi.useFakeTimers();
const toggle = document.getElementById("disable-feedback-toggle");
toggle.checked = true;
showFeedback(false, "Error");
clearFeedback();
// Advance past the timeout - should not throw
vi.advanceTimersByTime(5000);
vi.useRealTimers();
});
});
describe("updateActiveLessonInSidebar", () => {
beforeEach(() => {
document.body.innerHTML = `
<details class="module-container" data-module-id="mod1">
<summary class="module-header">Module 1</summary>
<div class="lessons-container">
<button class="lesson-list-item active" data-module-id="mod1" data-lesson-index="0">L1</button>
<button class="lesson-list-item" data-module-id="mod1" data-lesson-index="1">L2</button>
</div>
</details>
<details class="module-container" data-module-id="mod2">
<summary class="module-header">Module 2</summary>
<div class="lessons-container">
<button class="lesson-list-item" data-module-id="mod2" data-lesson-index="0">L1</button>
</div>
</details>
`;
// Mock scrollIntoView on all lesson items (not available in jsdom)
document.querySelectorAll(".lesson-list-item").forEach((el) => {
el.scrollIntoView = vi.fn();
});
});
test("updateActiveLessonInSidebar_ValidLesson_ActivatesCorrectItem", () => {
updateActiveLessonInSidebar("mod1", 1);
const items = document.querySelectorAll(".lesson-list-item");
expect(items[0].classList.contains("active")).toBe(false);
expect(items[1].classList.contains("active")).toBe(true);
});
test("updateActiveLessonInSidebar_DifferentModule_ExpandsParent", () => {
const details = document.querySelector('details[data-module-id="mod2"]');
expect(details.open).toBe(false);
updateActiveLessonInSidebar("mod2", 0);
expect(details.open).toBe(true);
const mod2Lesson = document.querySelector('.lesson-list-item[data-module-id="mod2"]');
expect(mod2Lesson.classList.contains("active")).toBe(true);
});
test("updateActiveLessonInSidebar_RemovesPreviousActive", () => {
const firstItem = document.querySelector('.lesson-list-item[data-module-id="mod1"][data-lesson-index="0"]');
expect(firstItem.classList.contains("active")).toBe(true);
updateActiveLessonInSidebar("mod2", 0);
expect(firstItem.classList.contains("active")).toBe(false);
});
test("updateActiveLessonInSidebar_NonExistentItem_DoesNotThrow", () => {
expect(() => {
updateActiveLessonInSidebar("nonexistent", 99);
}).not.toThrow();
// All active classes should still be removed
const activeItems = document.querySelectorAll(".lesson-list-item.active");
expect(activeItems.length).toBe(0);
});
test("updateActiveLessonInSidebar_ScrollsToLesson", () => {
const targetItem = document.querySelector('.lesson-list-item[data-module-id="mod1"][data-lesson-index="1"]');
updateActiveLessonInSidebar("mod1", 1);
expect(targetItem.scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "nearest" });
});
});
describe("computeLessonDifficulty - additional edge cases", () => {
test("computeLessonDifficulty_NoSolution_ReturnsMedium", () => {
expect(computeLessonDifficulty({ codePrefix: "" })).toBe("medium");
});
test("computeLessonDifficulty_SolutionNoBrace_ReturnsMedium", () => {
expect(
computeLessonDifficulty({
codePrefix: "",
solution: "color: red;"
})
).toBe("medium");
});
test("computeLessonDifficulty_CodePrefixWithBrace_IgnoresSolution", () => {
expect(
computeLessonDifficulty({
codePrefix: ".nav a {",
solution: ".nav a {\n color: white;\n}"
})
).toBe("easy");
});
test("computeLessonDifficulty_NullCodePrefix_ReturnsMedium", () => {
expect(computeLessonDifficulty({ codePrefix: null, solution: null })).toBe("medium");
});
});
describe("renderLesson - edge cases", () => {
test("renderLesson_NullInputEl_DoesNotThrow", () => {
const titleEl = document.getElementById("title");
const descriptionEl = document.getElementById("description");
const taskEl = document.getElementById("task");
const previewEl = document.getElementById("preview");
const prefixEl = document.getElementById("prefix");
const suffixEl = document.getElementById("suffix");
const lesson = { title: "Test", description: "Desc", task: "Task" };
expect(() => {
renderLesson(titleEl, descriptionEl, taskEl, previewEl, prefixEl, null, suffixEl, lesson);
}).not.toThrow();
});
});
describe("renderLevelIndicator - formatting", () => {
test("renderLevelIndicator_ContainsLabelSpan", () => {
const element = document.getElementById("level-indicator");
renderLevelIndicator(element, 5, 12);
const label = element.querySelector(".level-label");
expect(label).not.toBeNull();
expect(label.textContent).toBe("Lesson");
expect(element.textContent).toContain("5 / 12");
});
});
});

View File

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

234
tests/unit/router.test.js Normal file
View File

@@ -0,0 +1,234 @@
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
import { parseHash, updateHash, navigateTo, replaceHash, replaceTo, getShareableUrl, getSectionIds, RouteType } from "../../src/helpers/router.js";
describe("Router", () => {
let pushStateSpy;
let replaceStateSpy;
beforeEach(() => {
// Reset hash
window.location.hash = "";
pushStateSpy = vi.spyOn(history, "pushState").mockImplementation(() => {});
replaceStateSpy = vi.spyOn(history, "replaceState").mockImplementation(() => {});
});
afterEach(() => {
pushStateSpy.mockRestore();
replaceStateSpy.mockRestore();
});
describe("RouteType", () => {
test("RouteType_Constants_CorrectValues", () => {
expect(RouteType.HOME).toBe("home");
expect(RouteType.SECTION).toBe("section");
expect(RouteType.REFERENCE).toBe("reference");
expect(RouteType.LESSON).toBe("lesson");
expect(RouteType.LANGUAGE).toBe("language");
});
});
describe("parseHash", () => {
test("parseHash_EmptyHash_ReturnsHome", () => {
window.location.hash = "";
const result = parseHash();
expect(result).toEqual({ type: RouteType.HOME });
});
test("parseHash_HashOnly_ReturnsHome", () => {
window.location.hash = "#";
const result = parseHash();
expect(result).toEqual({ type: RouteType.HOME });
});
test.each([
["de", "de"],
["pl", "pl"],
["ar", "ar"],
["es", "es"],
["en", "en"],
["uk", "uk"]
])("parseHash_LanguageCode_%s_ReturnsLanguageRoute", (code, expectedLang) => {
window.location.hash = `#${code}`;
const result = parseHash();
expect(result).toEqual({ type: RouteType.LANGUAGE, lang: expectedLang });
});
test.each([
["css", "css"],
["html", "html"],
["markdown", "markdown"],
["javascript", "javascript"]
])("parseHash_SectionId_%s_ReturnsSectionRoute", (sectionId, expectedId) => {
window.location.hash = `#${sectionId}`;
const result = parseHash();
expect(result).toEqual({ type: RouteType.SECTION, sectionId: expectedId });
});
test("parseHash_ReferenceWithoutSubpage_ReturnsReferenceRouteNullRefId", () => {
window.location.hash = "#reference";
const result = parseHash();
expect(result).toEqual({ type: RouteType.REFERENCE, refId: null });
});
test("parseHash_ReferenceWithSubpage_ReturnsReferenceRouteWithRefId", () => {
window.location.hash = "#reference/css";
const result = parseHash();
expect(result).toEqual({ type: RouteType.REFERENCE, refId: "css" });
});
test("parseHash_ReferenceWithFlexboxSubpage_ReturnsCorrectRefId", () => {
window.location.hash = "#reference/flexbox";
const result = parseHash();
expect(result).toEqual({ type: RouteType.REFERENCE, refId: "flexbox" });
});
test("parseHash_SingleUnknownSegment_ReturnsLessonWithIndex0", () => {
window.location.hash = "#flexbox";
const result = parseHash();
expect(result).toEqual({ type: RouteType.LESSON, moduleId: "flexbox", lessonIndex: 0 });
});
test("parseHash_ModuleWithLessonIndex_ReturnsLessonRoute", () => {
window.location.hash = "#flexbox/2";
const result = parseHash();
expect(result).toEqual({ type: RouteType.LESSON, moduleId: "flexbox", lessonIndex: 2 });
});
test("parseHash_ModuleWithIndex0_ReturnsLessonRoute", () => {
window.location.hash = "#box-model/0";
const result = parseHash();
expect(result).toEqual({ type: RouteType.LESSON, moduleId: "box-model", lessonIndex: 0 });
});
test("parseHash_NegativeLessonIndex_ReturnsNull", () => {
window.location.hash = "#module/-1";
const result = parseHash();
expect(result).toBeNull();
});
test("parseHash_NonNumericLessonIndex_ReturnsNull", () => {
window.location.hash = "#module/abc";
const result = parseHash();
expect(result).toBeNull();
});
test("parseHash_ThreeOrMoreSegments_ReturnsNull", () => {
window.location.hash = "#a/b/c";
const result = parseHash();
expect(result).toBeNull();
});
test("parseHash_EmptyModuleIdWithIndex_ReturnsNull", () => {
// #/0 → parts = ["", "0"], moduleId is empty string (falsy)
window.location.hash = "#/0";
const result = parseHash();
expect(result).toBeNull();
});
});
describe("updateHash", () => {
test("updateHash_NewHash_CallsPushState", () => {
window.location.hash = "";
updateHash("flexbox", 2);
expect(pushStateSpy).toHaveBeenCalledWith(null, "", "#flexbox/2");
});
test("updateHash_SameHash_DoesNotCallPushState", () => {
window.location.hash = "#flexbox/2";
updateHash("flexbox", 2);
expect(pushStateSpy).not.toHaveBeenCalled();
});
test("updateHash_DifferentModule_CallsPushState", () => {
window.location.hash = "#flexbox/0";
updateHash("box-model", 0);
expect(pushStateSpy).toHaveBeenCalledWith(null, "", "#box-model/0");
});
});
describe("navigateTo", () => {
test("navigateTo_SectionRoute_CallsPushState", () => {
window.location.hash = "";
navigateTo("css");
expect(pushStateSpy).toHaveBeenCalledWith(null, "", "#css");
});
test("navigateTo_EmptyRoute_NavigatesToHash", () => {
window.location.hash = "#something";
navigateTo("");
expect(pushStateSpy).toHaveBeenCalledWith(null, "", "#");
});
test("navigateTo_SameHash_DoesNotCallPushState", () => {
window.location.hash = "#css";
navigateTo("css");
expect(pushStateSpy).not.toHaveBeenCalled();
});
});
describe("replaceHash", () => {
test("replaceHash_ValidArgs_CallsReplaceState", () => {
replaceHash("flexbox", 3);
expect(replaceStateSpy).toHaveBeenCalledWith(null, "", "#flexbox/3");
});
test("replaceHash_Index0_FormatsCorrectly", () => {
replaceHash("box-model", 0);
expect(replaceStateSpy).toHaveBeenCalledWith(null, "", "#box-model/0");
});
});
describe("replaceTo", () => {
test("replaceTo_Route_CallsReplaceState", () => {
replaceTo("css");
expect(replaceStateSpy).toHaveBeenCalledWith(null, "", "#css");
});
test("replaceTo_EmptyRoute_ReplacesToHash", () => {
replaceTo("");
expect(replaceStateSpy).toHaveBeenCalledWith(null, "", "#");
});
test("replaceTo_ReferenceRoute_FormatsCorrectly", () => {
replaceTo("reference/flexbox");
expect(replaceStateSpy).toHaveBeenCalledWith(null, "", "#reference/flexbox");
});
});
describe("getShareableUrl", () => {
test("getShareableUrl_ValidArgs_ReturnsFullUrl", () => {
const url = getShareableUrl("flexbox", 2);
expect(url).toContain("#flexbox/2");
expect(url).toMatch(/^https?:\/\/.+#flexbox\/2$/);
});
test("getShareableUrl_Index0_IncludesIndex", () => {
const url = getShareableUrl("box-model", 0);
expect(url).toContain("#box-model/0");
});
});
describe("getSectionIds", () => {
test("getSectionIds_ReturnsCopy_NotOriginalArray", () => {
const ids1 = getSectionIds();
const ids2 = getSectionIds();
expect(ids1).toEqual(ids2);
expect(ids1).not.toBe(ids2); // Different references
});
test("getSectionIds_ContainsExpectedSections", () => {
const ids = getSectionIds();
expect(ids).toContain("css");
expect(ids).toContain("html");
expect(ids).toContain("markdown");
expect(ids).toContain("javascript");
});
test("getSectionIds_MutatingCopy_DoesNotAffectOriginal", () => {
const ids = getSectionIds();
ids.push("custom");
const freshIds = getSectionIds();
expect(freshIds).not.toContain("custom");
});
});
});

176
tests/unit/sections.test.js Normal file
View File

@@ -0,0 +1,176 @@
import { describe, test, expect } from "vitest";
import { sections, getSection, getSectionList, getModuleSection, getModulesBySection } from "../../src/config/sections.js";
describe("Sections Config", () => {
describe("sections constant", () => {
test("sections_AllDefined_HasFiveSections", () => {
expect(Object.keys(sections)).toHaveLength(5);
expect(sections).toHaveProperty("css");
expect(sections).toHaveProperty("html");
expect(sections).toHaveProperty("tailwind");
expect(sections).toHaveProperty("markdown");
expect(sections).toHaveProperty("javascript");
});
test("sections_EachSection_HasRequiredFields", () => {
for (const [key, section] of Object.entries(sections)) {
expect(section.id).toBe(key);
expect(section.title).toBeTruthy();
expect(section.description).toBeTruthy();
expect(section.color).toMatch(/^#[0-9a-f]{6}$/);
expect(typeof section.order).toBe("number");
}
});
});
describe("getSection", () => {
test.each([
["css", "CSS"],
["html", "HTML"],
["tailwind", "Tailwind CSS"],
["markdown", "Markdown"],
["javascript", "JavaScript"]
])("getSection_%s_ReturnsCorrectSection", (id, expectedTitle) => {
const section = getSection(id);
expect(section).not.toBeNull();
expect(section.id).toBe(id);
expect(section.title).toBe(expectedTitle);
});
test("getSection_NonExistentId_ReturnsNull", () => {
expect(getSection("nonexistent")).toBeNull();
});
test("getSection_Undefined_ReturnsNull", () => {
expect(getSection(undefined)).toBeNull();
});
test("getSection_EmptyString_ReturnsNull", () => {
expect(getSection("")).toBeNull();
});
});
describe("getSectionList", () => {
test("getSectionList_Default_ReturnsSortedByOrder", () => {
const list = getSectionList();
expect(list).toHaveLength(5);
// Verify sorted by order
for (let i = 1; i < list.length; i++) {
expect(list[i].order).toBeGreaterThan(list[i - 1].order);
}
});
test("getSectionList_Default_CSSIsFirst", () => {
const list = getSectionList();
expect(list[0].id).toBe("css");
});
test("getSectionList_Default_JavaScriptIsLast", () => {
const list = getSectionList();
expect(list[list.length - 1].id).toBe("javascript");
});
test("getSectionList_Default_ContainsAllSections", () => {
const list = getSectionList();
const ids = list.map((s) => s.id);
expect(ids).toContain("css");
expect(ids).toContain("html");
expect(ids).toContain("tailwind");
expect(ids).toContain("markdown");
expect(ids).toContain("javascript");
});
});
describe("getModuleSection", () => {
test("getModuleSection_ExplicitSection_UsesExplicitValue", () => {
const module = { mode: "css", section: "html" };
expect(getModuleSection(module)).toBe("html");
});
test.each([
["css", "css"],
["html", "html"],
["tailwind", "tailwind"],
["markdown", "markdown"],
["javascript", "javascript"]
])("getModuleSection_Mode%s_InfersCorrectSection", (mode, expectedSection) => {
const module = { mode };
expect(getModuleSection(module)).toBe(expectedSection);
});
test("getModuleSection_NoMode_DefaultsToCss", () => {
expect(getModuleSection({})).toBe("css");
});
test("getModuleSection_UndefinedMode_DefaultsToCss", () => {
expect(getModuleSection({ mode: undefined })).toBe("css");
});
test("getModuleSection_UnknownMode_DefaultsToCss", () => {
expect(getModuleSection({ mode: "unknown-mode" })).toBe("css");
});
test("getModuleSection_ExplicitSectionOverridesMode_UsesSection", () => {
const module = { mode: "html", section: "tailwind" };
expect(getModuleSection(module)).toBe("tailwind");
});
});
describe("getModulesBySection", () => {
const testModules = [
{ id: "css-basics", mode: "css" },
{ id: "flexbox", mode: "css" },
{ id: "html-elements", mode: "html" },
{ id: "tailwind-intro", mode: "tailwind" },
{ id: "markdown-basics", mode: "markdown" },
{ id: "welcome", mode: "css", excludeFromProgress: true },
{ id: "playground", mode: "css", excludeFromProgress: true }
];
test("getModulesBySection_Css_ReturnsCssModules", () => {
const result = getModulesBySection(testModules, "css");
expect(result).toHaveLength(2);
expect(result.map((m) => m.id)).toEqual(["css-basics", "flexbox"]);
});
test("getModulesBySection_Html_ReturnsHtmlModules", () => {
const result = getModulesBySection(testModules, "html");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("html-elements");
});
test("getModulesBySection_Tailwind_ReturnsTailwindModules", () => {
const result = getModulesBySection(testModules, "tailwind");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("tailwind-intro");
});
test("getModulesBySection_ExcludesFromProgress_FiltersOut", () => {
const result = getModulesBySection(testModules, "css");
const ids = result.map((m) => m.id);
expect(ids).not.toContain("welcome");
expect(ids).not.toContain("playground");
});
test("getModulesBySection_EmptyModules_ReturnsEmptyArray", () => {
const result = getModulesBySection([], "css");
expect(result).toEqual([]);
});
test("getModulesBySection_NonExistentSection_ReturnsEmptyArray", () => {
const result = getModulesBySection(testModules, "nonexistent");
expect(result).toEqual([]);
});
test("getModulesBySection_ExplicitSectionOverride_IncludesModule", () => {
const modules = [
{ id: "special", mode: "css", section: "html" },
{ id: "normal-html", mode: "html" }
];
const result = getModulesBySection(modules, "html");
expect(result).toHaveLength(2);
expect(result.map((m) => m.id)).toContain("special");
});
});
});

View File

@@ -0,0 +1,735 @@
import { describe, test, expect, vi, beforeEach } from "vitest";
import { validateUserCode, validateCssCode } from "../../src/helpers/validator.js";
describe("Validator Extended Coverage", () => {
describe("validateUserCode mode dispatch", () => {
test("validateUserCode_NoMode_DefaultsToCss", () => {
const result = validateUserCode("color: red;", {
validations: [{ type: "contains", value: "color: red" }]
});
expect(result.isValid).toBe(true);
});
test("validateUserCode_CssMode_UsesCssValidator", () => {
const result = validateUserCode("display: flex;", {
mode: "css",
validations: [{ type: "contains", value: "display: flex" }]
});
expect(result.isValid).toBe(true);
});
test("validateUserCode_TailwindMode_UsesTailwindValidator", () => {
const result = validateUserCode("flex items-center", {
mode: "tailwind",
validations: [{ type: "contains_class", value: "flex" }]
});
expect(result.isValid).toBe(true);
});
test("validateUserCode_HtmlMode_UsesHtmlValidator", () => {
const result = validateUserCode("<div>Hello</div>", {
mode: "html",
validations: [{ type: "element_exists", value: "div" }]
});
expect(result.isValid).toBe(true);
});
test("validateUserCode_UnknownMode_DefaultsToCss", () => {
const result = validateUserCode("color: red;", {
mode: "javascript",
validations: [{ type: "contains", value: "color: red" }]
});
expect(result.isValid).toBe(true);
});
test("validateUserCode_NullLesson_Throws", () => {
expect(() => validateUserCode("anything", null)).toThrow();
});
test("validateUserCode_UndefinedLesson_Throws", () => {
expect(() => validateUserCode("anything", undefined)).toThrow();
});
});
describe("Tailwind validation", () => {
test("tailwind_ContainsClass_Pass", () => {
const result = validateUserCode("flex items-center justify-between", {
mode: "tailwind",
validations: [{ type: "contains_class", value: "flex" }]
});
expect(result.isValid).toBe(true);
expect(result.validCases).toBe(1);
});
test("tailwind_ContainsClass_Fail_ReturnsMessage", () => {
const result = validateUserCode("items-center", {
mode: "tailwind",
validations: [{ type: "contains_class", value: "flex", message: "Add flex class" }]
});
expect(result.isValid).toBe(false);
expect(result.message).toBe("Add flex class");
});
test("tailwind_ContainsClass_Fail_DefaultMessage", () => {
const result = validateUserCode("items-center", {
mode: "tailwind",
validations: [{ type: "contains_class", value: "flex" }]
});
expect(result.isValid).toBe(false);
expect(result.message).toContain("flex");
});
test("tailwind_ContainsClass_PartialMatch_Fails", () => {
// "flex-1" contains "flex" as substring but split should not match
const result = validateUserCode("flex-1 items-center", {
mode: "tailwind",
validations: [{ type: "contains_class", value: "flex" }]
});
expect(result.isValid).toBe(false);
});
test("tailwind_ContainsPattern_Pass", () => {
const result = validateUserCode("text-lg font-bold", {
mode: "tailwind",
validations: [{ type: "contains_pattern", value: "text-\\w+" }]
});
expect(result.isValid).toBe(true);
});
test("tailwind_ContainsPattern_Fail_ReturnsMessage", () => {
const result = validateUserCode("font-bold", {
mode: "tailwind",
validations: [{ type: "contains_pattern", value: "text-\\w+", message: "Add text size" }]
});
expect(result.isValid).toBe(false);
expect(result.message).toBe("Add text size");
});
test("tailwind_ContainsPattern_Fail_DefaultMessage", () => {
const result = validateUserCode("font-bold", {
mode: "tailwind",
validations: [{ type: "contains_pattern", value: "text-\\w+" }]
});
expect(result.isValid).toBe(false);
expect(result.message).toContain("pattern");
});
test("tailwind_DefaultType_FallsBackToContains", () => {
const result = validateUserCode("flex items-center", {
mode: "tailwind",
validations: [{ type: "contains", value: "items-center" }]
});
expect(result.isValid).toBe(true);
});
test("tailwind_NoValidations_ReturnsValid", () => {
const result = validateUserCode("flex", { mode: "tailwind" });
expect(result.isValid).toBe(true);
expect(result.message).toContain("No validations specified");
});
test("tailwind_NullLesson_ReturnsValid", () => {
const result = validateUserCode("flex", { mode: "tailwind", validations: null });
// validateTailwindClasses checks !lesson.validations
expect(result.isValid).toBe(true);
});
test("tailwind_MultipleValidations_AllPass", () => {
const result = validateUserCode("flex items-center gap-4", {
mode: "tailwind",
validations: [
{ type: "contains_class", value: "flex" },
{ type: "contains_class", value: "items-center" },
{ type: "contains_class", value: "gap-4" }
]
});
expect(result.isValid).toBe(true);
expect(result.validCases).toBe(3);
});
test("tailwind_MultipleValidations_EarlyReturn", () => {
const result = validateUserCode("flex", {
mode: "tailwind",
validations: [
{ type: "contains_class", value: "flex" },
{ type: "contains_class", value: "items-center", message: "Missing items-center" },
{ type: "contains_class", value: "gap-4" }
]
});
expect(result.isValid).toBe(false);
expect(result.message).toBe("Missing items-center");
expect(result.validCases).toBe(1);
});
test("tailwind_WhitespaceHandling_LeadingTrailing", () => {
const result = validateUserCode(" flex items-center ", {
mode: "tailwind",
validations: [{ type: "contains_class", value: "flex" }]
});
expect(result.isValid).toBe(true);
});
test("tailwind_EmptyUserClasses_Fails", () => {
const result = validateUserCode("", {
mode: "tailwind",
validations: [{ type: "contains_class", value: "flex" }]
});
expect(result.isValid).toBe(false);
});
});
describe("HTML validation - sibling type", () => {
test("sibling_ValidOrder_Passes", () => {
const result = validateUserCode("<h1>Title</h1><p>Content</p>", {
mode: "html",
validations: [{ type: "sibling", value: { first: "h1", then: "p" } }]
});
expect(result.isValid).toBe(true);
});
test("sibling_NonAdjacentButAfter_Passes", () => {
const result = validateUserCode("<h1>Title</h1><span>Middle</span><p>Content</p>", {
mode: "html",
validations: [{ type: "sibling", value: { first: "h1", then: "p" } }]
});
expect(result.isValid).toBe(true);
});
test("sibling_WrongOrder_Fails", () => {
const result = validateUserCode("<p>Content</p><h1>Title</h1>", {
mode: "html",
validations: [{ type: "sibling", value: { first: "h1", then: "p" } }]
});
// h1 is after p, so p is not a sibling after h1 - but wait, h1 exists and p is before h1...
// Actually h1 exists. nextElementSibling of h1 is nothing. So it fails.
expect(result.isValid).toBe(false);
});
test("sibling_FirstNotFound_Fails", () => {
const result = validateUserCode("<p>Content</p>", {
mode: "html",
validations: [{ type: "sibling", value: { first: "h1", then: "p" }, message: "h1 not found" }]
});
expect(result.isValid).toBe(false);
expect(result.message).toBe("h1 not found");
});
test("sibling_ThenNotFound_Fails", () => {
const result = validateUserCode("<h1>Title</h1><span>Only span</span>", {
mode: "html",
validations: [{ type: "sibling", value: { first: "h1", then: "p" } }]
});
expect(result.isValid).toBe(false);
});
test("sibling_DefaultMessage_ContainsBothSelectors", () => {
const result = validateUserCode("<div>Only div</div>", {
mode: "html",
validations: [{ type: "sibling", value: { first: "h1", then: "p" } }]
});
expect(result.isValid).toBe(false);
expect(result.message).toContain("p");
expect(result.message).toContain("h1");
});
test("sibling_NoFollowingSiblings_Fails", () => {
const result = validateUserCode("<div><h1>Title</h1></div>", {
mode: "html",
validations: [{ type: "sibling", value: { first: "h1", then: "p" } }]
});
expect(result.isValid).toBe(false);
});
});
describe("HTML validation - not_contains type", () => {
test("htmlNotContains_AbsentText_Passes", () => {
const result = validateUserCode("<p>Hello</p>", {
mode: "html",
validations: [{ type: "not_contains", value: "class=" }]
});
expect(result.isValid).toBe(true);
});
test("htmlNotContains_PresentText_Fails", () => {
const result = validateUserCode('<p class="red">Hello</p>', {
mode: "html",
validations: [{ type: "not_contains", value: "class=", message: "Remove classes" }]
});
expect(result.isValid).toBe(false);
expect(result.message).toBe("Remove classes");
});
test("htmlNotContains_DefaultMessage", () => {
const result = validateUserCode('<p class="red">Hello</p>', {
mode: "html",
validations: [{ type: "not_contains", value: "class=" }]
});
expect(result.isValid).toBe(false);
expect(result.message).toContain("should not include");
});
});
describe("HTML validation - regex type", () => {
test("htmlRegex_MatchingPattern_Passes", () => {
const result = validateUserCode('<img src="photo.jpg" alt="A photo">', {
mode: "html",
validations: [{ type: "regex", value: 'alt="[^"]+"' }]
});
expect(result.isValid).toBe(true);
});
test("htmlRegex_NonMatchingPattern_Fails", () => {
const result = validateUserCode('<img src="photo.jpg">', {
mode: "html",
validations: [{ type: "regex", value: 'alt="[^"]+"', message: "Add alt text" }]
});
expect(result.isValid).toBe(false);
expect(result.message).toBe("Add alt text");
});
test("htmlRegex_DefaultMessage", () => {
const result = validateUserCode("<p>Hello</p>", {
mode: "html",
validations: [{ type: "regex", value: "<h1>" }]
});
expect(result.isValid).toBe(false);
expect(result.message).toContain("pattern");
});
});
describe("HTML validation - unknown type", () => {
test("htmlUnknownType_SkipsAndPasses", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const result = validateUserCode("<p>Hello</p>", {
mode: "html",
validations: [{ type: "unknown_type", value: "anything" }]
});
expect(result.isValid).toBe(true);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown HTML validation type"));
warnSpy.mockRestore();
});
});
describe("HTML validation - element_count fallback (>0)", () => {
test("elementCount_NoCountNoMin_ChecksGreaterThanZero_Pass", () => {
const result = validateUserCode("<ul><li>Item</li></ul>", {
mode: "html",
validations: [{ type: "element_count", value: { selector: "li" } }]
});
expect(result.isValid).toBe(true);
});
test("elementCount_NoCountNoMin_NoElements_Fails", () => {
const result = validateUserCode("<ul></ul>", {
mode: "html",
validations: [{ type: "element_count", value: { selector: "li" } }]
});
expect(result.isValid).toBe(false);
});
});
describe("HTML validation - attribute_value edge cases", () => {
test("attributeValue_ElementNotFound_Fails", () => {
const result = validateUserCode("<p>Hello</p>", {
mode: "html",
validations: [{ type: "attribute_value", value: { selector: "input", attr: "type", value: "email" } }]
});
expect(result.isValid).toBe(false);
});
test("attributeValue_NullValue_ChecksExists", () => {
const result = validateUserCode('<input data-test="anything">', {
mode: "html",
validations: [{ type: "attribute_value", value: { selector: "input", attr: "data-test", value: null } }]
});
expect(result.isValid).toBe(true);
});
test("attributeValue_NullValue_AttributeMissing_Fails", () => {
const result = validateUserCode("<input>", {
mode: "html",
validations: [{ type: "attribute_value", value: { selector: "input", attr: "data-test", value: null } }]
});
expect(result.isValid).toBe(false);
});
});
describe("HTML validation - element_text edge cases", () => {
test("elementText_ElementNotFound_Fails", () => {
const result = validateUserCode("<p>Hello</p>", {
mode: "html",
validations: [{ type: "element_text", value: { selector: "button", text: "Submit" } }]
});
expect(result.isValid).toBe(false);
});
test("elementText_EmptyTextContent_FailsForNonEmptyExpected", () => {
const result = validateUserCode("<button></button>", {
mode: "html",
validations: [{ type: "element_text", value: { selector: "button", text: "Submit" } }]
});
expect(result.isValid).toBe(false);
});
test("elementText_EmptyExpectedText_MatchesEmptyElement", () => {
const result = validateUserCode("<button></button>", {
mode: "html",
validations: [{ type: "element_text", value: { selector: "button", text: "" } }]
});
expect(result.isValid).toBe(true);
});
});
describe("CSS validation - containsValidation wholeWord option", () => {
test("contains_WholeWord_ExactMatch_Passes", () => {
const result = validateUserCode("color: red;", {
validations: [{ type: "contains", value: "red", options: { wholeWord: true } }]
});
expect(result.isValid).toBe(true);
});
test("contains_WholeWord_PartialMatch_Fails", () => {
const result = validateUserCode("color: darkred;", {
validations: [{ type: "contains", value: "red", options: { wholeWord: true } }]
});
expect(result.isValid).toBe(false);
});
test("contains_WholeWord_CaseInsensitive_Passes", () => {
const result = validateUserCode("COLOR: RED;", {
validations: [{ type: "contains", value: "red", options: { wholeWord: true, caseSensitive: false } }]
});
expect(result.isValid).toBe(true);
});
test("contains_WholeWord_SpecialChars_Escaped", () => {
// \b doesn't match at non-word chars like ".", so use a word value with special chars around it
const result = validateUserCode("value: calc(100% - 20px);", {
validations: [{ type: "contains", value: "calc", options: { wholeWord: true } }]
});
expect(result.isValid).toBe(true);
// "calc" should not match "recalculate"
const failResult = validateUserCode("/* recalculate */", {
validations: [{ type: "contains", value: "calc", options: { wholeWord: true } }]
});
expect(failResult.isValid).toBe(false);
});
});
describe("CSS validation - regexValidation options", () => {
test("regex_CaseInsensitive_Passes", () => {
const result = validateUserCode("COLOR: RED;", {
validations: [{ type: "regex", value: "color:\\s*red", options: { caseSensitive: false } }]
});
expect(result.isValid).toBe(true);
});
test("regex_CaseSensitive_Default_FailsOnCaseMismatch", () => {
const result = validateUserCode("COLOR: RED;", {
validations: [{ type: "regex", value: "color:\\s*red" }]
});
expect(result.isValid).toBe(false);
});
test("regex_MultilineFalse_DoesNotMatchAcrossLines", () => {
const code = "body {\n color: red;\n}";
// With multiline=false, ^ should not match beginning of each line
const result = validateUserCode(code, {
validations: [{ type: "regex", value: "^\\s*color", options: { multiline: false } }]
});
expect(result.isValid).toBe(false);
});
test("regex_MultilineTrue_Default_MatchesEachLine", () => {
const code = "body {\n color: red;\n}";
const result = validateUserCode(code, {
validations: [{ type: "regex", value: "^\\s*color", options: { multiline: true } }]
});
expect(result.isValid).toBe(true);
});
test("regex_InvalidPattern_ReturnsFalse", () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const result = validateUserCode("color: red;", {
validations: [{ type: "regex", value: "[invalid(regex" }]
});
expect(result.isValid).toBe(false);
expect(errorSpy).toHaveBeenCalled();
errorSpy.mockRestore();
});
test("regex_EmptyPattern_MatchesEverything", () => {
const result = validateUserCode("color: red;", {
validations: [{ type: "regex", value: "" }]
});
expect(result.isValid).toBe(true);
});
});
describe("CSS validation - propertyValueValidation edge cases", () => {
test("propertyValue_PropertyNotFound_Fails", () => {
const result = validateUserCode("color: red;", {
validations: [
{
type: "property_value",
value: { property: "display", expected: "flex" }
}
]
});
expect(result.isValid).toBe(false);
});
test("propertyValue_ExactMatch_Passes", () => {
const result = validateUserCode("display: flex;", {
validations: [
{
type: "property_value",
value: { property: "display", expected: "flex" },
options: { exact: true }
}
]
});
expect(result.isValid).toBe(true);
});
test("propertyValue_ExactMatch_CaseMismatch_Fails", () => {
const result = validateUserCode("display: FLEX;", {
validations: [
{
type: "property_value",
value: { property: "display", expected: "flex" },
options: { exact: true }
}
]
});
expect(result.isValid).toBe(false);
});
test("propertyValue_FlexibleMatch_CaseInsensitive", () => {
const result = validateUserCode("display: FLEX;", {
validations: [
{
type: "property_value",
value: { property: "display", expected: "flex" }
}
]
});
expect(result.isValid).toBe(true);
});
test("propertyValue_ShorthandProperty_Passes", () => {
const result = validateUserCode("margin: 10px 20px;", {
validations: [
{
type: "property_value",
value: { property: "margin", expected: "10px 20px" }
}
]
});
expect(result.isValid).toBe(true);
});
test("propertyValue_DefaultMessage_IncludesPropertyAndExpected", () => {
const result = validateUserCode("color: red;", {
validations: [
{
type: "property_value",
value: { property: "display", expected: "flex" }
}
]
});
expect(result.isValid).toBe(false);
expect(result.message).toContain("display");
expect(result.message).toContain("flex");
});
});
describe("CSS validation - syntaxValidation", () => {
test("syntax_ValidCss_Passes", () => {
const result = validateUserCode("div { color: red; }", {
validations: [{ type: "syntax" }]
});
expect(result.isValid).toBe(true);
});
});
describe("CSS validation - custom edge cases", () => {
test("custom_NoValidatorFunction_ReturnsEarlyWithOriginalResult", () => {
const result = validateUserCode("color: red;", {
validations: [{ type: "custom" }]
});
// When validator is falsy, validationPassed stays false, but result.isValid was never set to false
// The function returns early with the unmodified result (isValid: true)
expect(result.isValid).toBe(true);
});
test("custom_NonFunctionValidator_ReturnsEarlyWithOriginalResult", () => {
const result = validateUserCode("color: red;", {
validations: [{ type: "custom", validator: "not-a-function" }]
});
// Same behavior: validator check fails, validationPassed stays false, returns unmodified result
expect(result.isValid).toBe(true);
});
test("custom_ValidatorReturnsNoMessage_UsesMessage", () => {
const result = validateUserCode("color: red;", {
validations: [
{
type: "custom",
validator: () => ({ isValid: false }),
message: "Fallback message"
}
]
});
expect(result.isValid).toBe(false);
expect(result.message).toBe("Fallback message");
});
test("custom_ValidatorReturnsNoMessage_NoLessonMessage_DefaultMessage", () => {
const result = validateUserCode("color: red;", {
validations: [
{
type: "custom",
validator: () => ({ isValid: false })
}
]
});
expect(result.isValid).toBe(false);
expect(result.message).toContain("does not meet the requirements");
});
});
describe("CSS validation - unknown type", () => {
test("unknownType_WarnsAndContinues", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const result = validateUserCode("color: red;", {
validations: [
{ type: "invented_type", value: "anything" },
{ type: "contains", value: "color: red" }
]
});
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown validation type"));
// The unknown type is skipped (continue), then the next validation passes
expect(result.isValid).toBe(true);
warnSpy.mockRestore();
});
});
describe("CSS validation - empty and whitespace input", () => {
test("emptyString_ContainsValidation_Fails", () => {
const result = validateUserCode("", {
validations: [{ type: "contains", value: "color" }]
});
expect(result.isValid).toBe(false);
});
test("whitespaceOnly_ContainsValidation_Fails", () => {
const result = validateUserCode(" \n\t ", {
validations: [{ type: "contains", value: "color" }]
});
expect(result.isValid).toBe(false);
});
test("emptyString_NotContains_Passes", () => {
const result = validateUserCode("", {
validations: [{ type: "not_contains", value: "color" }]
});
expect(result.isValid).toBe(true);
});
});
describe("CSS validation - validCases and totalCases tracking", () => {
test("allPassingValidations_ValidCasesEqualsTotalCases", () => {
const result = validateUserCode("display: flex; color: red;", {
validations: [
{ type: "contains", value: "display: flex" },
{ type: "contains", value: "color: red" }
]
});
expect(result.isValid).toBe(true);
expect(result.validCases).toBe(2);
expect(result.totalCases).toBe(2);
});
test("firstValidationFails_ValidCasesIs0", () => {
const result = validateUserCode("color: red;", {
validations: [
{ type: "contains", value: "display: flex" },
{ type: "contains", value: "color: red" }
]
});
expect(result.isValid).toBe(false);
expect(result.validCases).toBe(0);
expect(result.totalCases).toBe(2);
});
test("secondValidationFails_ValidCasesIs1", () => {
const result = validateUserCode("display: flex;", {
validations: [
{ type: "contains", value: "display: flex" },
{ type: "contains", value: "color: red" }
]
});
expect(result.isValid).toBe(false);
expect(result.validCases).toBe(1);
expect(result.totalCases).toBe(2);
});
});
describe("CSS validation - special regex metacharacters in contains", () => {
test("contains_DotInValue_TreatedAsLiteral", () => {
// ".class" should match literally, not any char + "class"
const result = validateUserCode(".card { color: red; }", {
validations: [{ type: "contains", value: ".card" }]
});
expect(result.isValid).toBe(true);
});
test("contains_BracketsInValue_TreatedAsLiteral", () => {
const result = validateUserCode("content: '[test]';", {
validations: [{ type: "contains", value: "[test]" }]
});
expect(result.isValid).toBe(true);
});
});
describe("HTML validation - deeply nested parent_child", () => {
test("parentChild_DeeplyNested_Passes", () => {
const html = "<div><section><article><p>Deep</p></article></section></div>";
const result = validateUserCode(html, {
mode: "html",
validations: [{ type: "parent_child", value: { parent: "div", child: "p" } }]
});
expect(result.isValid).toBe(true);
});
});
describe("HTML validation - validCases tracking", () => {
test("htmlAllPass_ValidCasesEqualsTotal", () => {
const result = validateUserCode("<h1>Title</h1><p>Content</p>", {
mode: "html",
validations: [
{ type: "element_exists", value: "h1" },
{ type: "element_exists", value: "p" }
]
});
expect(result.isValid).toBe(true);
expect(result.validCases).toBe(2);
expect(result.totalCases).toBe(2);
});
test("htmlPartialPass_EarlyReturn", () => {
const result = validateUserCode("<h1>Title</h1>", {
mode: "html",
validations: [
{ type: "element_exists", value: "h1" },
{ type: "element_exists", value: "p", message: "Need paragraph" }
]
});
expect(result.isValid).toBe(false);
expect(result.validCases).toBe(1);
expect(result.message).toBe("Need paragraph");
});
});
});

View File

@@ -226,6 +226,86 @@ describe("CSS Validator", () => {
});
});
describe("JavaScript Validator", () => {
describe("validateUserCode with mode: javascript", () => {
it("should validate contains correctly for JavaScript", () => {
const userCode = 'const name = "Alice";';
const lesson = {
mode: "javascript",
validations: [{ type: "contains", value: "const", message: "Use const" }]
};
const result = validateUserCode(userCode, lesson);
expect(result.isValid).toBe(true);
expect(result.validCases).toBe(1);
});
it("should validate regex correctly for JavaScript", () => {
const userCode = 'const name = "Alice";';
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 validate not_contains correctly for JavaScript", () => {
const userCode = 'const name = "Alice";';
const lesson = {
mode: "javascript",
validations: [{ type: "not_contains", value: "var", message: "Do not use var" }]
};
const result = validateUserCode(userCode, lesson);
expect(result.isValid).toBe(true);
const failCode = 'var name = "Alice";';
const failResult = validateUserCode(failCode, lesson);
expect(failResult.isValid).toBe(false);
expect(failResult.message).toBe("Do not use var");
});
it("should return invalid for missing code", () => {
const userCode = "";
const lesson = {
mode: "javascript",
validations: [{ type: "contains", value: "const", message: "Use const" }]
};
const result = validateUserCode(userCode, lesson);
expect(result.isValid).toBe(false);
});
it("should pass with no validations", () => {
const userCode = 'const x = 1;';
const lesson = { mode: "javascript" };
const result = validateUserCode(userCode, lesson);
expect(result.isValid).toBe(true);
expect(result.message).toContain("No validations specified");
});
it("should handle multiple validations with early return on failure", () => {
const userCode = 'const name = "Alice";';
const lesson = {
mode: "javascript",
validations: [
{ type: "contains", value: "const", message: "Use const" },
{ type: "contains", value: "let", message: "Use let" },
{ type: "contains", value: "name", message: "Declare name" }
]
};
const result = validateUserCode(userCode, lesson);
expect(result.isValid).toBe(false);
expect(result.message).toBe("Use let");
expect(result.validCases).toBe(1);
});
});
});
describe("HTML Validator", () => {
describe("validateUserCode with mode: html", () => {
it("should validate element_exists correctly", () => {

376
wave.yaml Normal file
View File

@@ -0,0 +1,376 @@
adapters:
claude:
binary: claude
default_permissions:
allowed_tools:
- Read
- Write
- Edit
- Bash
deny: []
mode: headless
output_format: json
project_files:
- CLAUDE.md
- .claude/settings.json
apiVersion: v1
kind: WaveManifest
metadata:
description: An interactive platform for learning CSS through practical challenges
name: code-crispies
ontology:
telos: Interactive self-learning platform for web technologies (CSS, HTML, JavaScript, Markdown)
personas:
auditor:
adapter: claude
description: Security review and quality assurance
model: claude-haiku
permissions:
allowed_tools:
- Read
- Write
- Grep
- Glob
- Bash
deny:
- Edit(*)
- Bash(rm -rf /*)
- Bash(git push*)
- Bash(git commit*)
system_prompt_file: .wave/personas/auditor.md
temperature: 0.1
craftsman:
adapter: claude
description: Code implementation and testing
model: claude-opus
permissions:
allowed_tools:
- Read
- Write
- Edit
- Bash
deny:
- Bash(rm -rf /*)
system_prompt_file: .wave/personas/craftsman.md
temperature: 0.7
debugger:
adapter: claude
description: Systematic debugging and root cause analysis
model: claude-opus
permissions:
allowed_tools:
- Read
- Write
- Glob
- Grep
- Bash
deny:
- Edit(*)
- Bash(rm -rf /*)
- Bash(git push*)
- Bash(git commit*)
system_prompt_file: .wave/personas/debugger.md
temperature: 0.1
gitea-analyst:
adapter: claude
description: Gitea issue analysis and scanning
permissions:
allowed_tools:
- Read
- Write
- Bash(tea issues view*)
- Bash(tea issues list*)
- Bash(tea releases list*)
- Bash(tea pulls view*)
- Bash(tea pulls list*)
- Bash(tea --version)
- Bash(git log*)
- Bash(git status*)
- Bash(ls *)
deny:
- Bash(tea issues edit*)
- Bash(tea issues create*)
- Bash(tea issues close*)
- Bash(gh *)
- Bash(glab *)
- Edit(*)
system_prompt_file: .wave/personas/gitea-analyst.md
temperature: 0.1
gitea-commenter:
adapter: claude
description: Posts comments on Gitea issues and pull requests
permissions:
allowed_tools:
- Read
- Write
- Bash(tea issues comment*)
- Bash(tea pulls create*)
- Bash(tea --version)
- Bash(git push*)
- Bash(git status*)
- Bash(git log*)
- Bash(git remote*)
- Bash(git diff*)
deny:
- Bash(tea issues edit*)
- Bash(tea issues close*)
- Bash(tea pulls merge*)
- Bash(tea pulls close*)
- Bash(gh *)
- Bash(glab *)
- Edit(*)
system_prompt_file: .wave/personas/gitea-commenter.md
temperature: 0.2
gitea-enhancer:
adapter: claude
description: Gitea issue enhancement and improvement
permissions:
allowed_tools:
- Read
- Write
- Bash(tea issues edit*)
- Bash(tea issues view*)
- Bash(tea --version)
deny:
- Bash(tea issues create*)
- Bash(tea issues close*)
- Bash(gh *)
- Bash(glab *)
- Edit(*)
system_prompt_file: .wave/personas/gitea-enhancer.md
temperature: 0.2
gitea-scoper:
adapter: claude
description: Gitea epic analysis, decomposition, and sub-issue creation
permissions:
allowed_tools:
- Read
- Write
- Bash(tea issues create*)
- Bash(tea issues view*)
- Bash(tea issues list*)
- Bash(tea --version)
deny:
- Bash(tea issues edit*)
- Bash(tea issues close*)
- Bash(gh *)
- Bash(glab *)
- Edit(*)
system_prompt_file: .wave/personas/gitea-scoper.md
temperature: 0.1
implementer:
adapter: claude
description: Execution specialist for code changes and structured output
model: claude-opus
permissions:
allowed_tools:
- Read
- Write
- Edit
- Bash
- Glob
- Grep
deny:
- Bash(rm -rf /*)
- Bash(sudo *)
system_prompt_file: .wave/personas/implementer.md
temperature: 0.3
navigator:
adapter: claude
description: Read-only codebase exploration and analysis
model: claude-haiku
permissions:
allowed_tools:
- Read
- Write
- Glob
- Grep
- Bash(git log*)
- Bash(git status*)
deny:
- Edit(*)
- Bash(git commit*)
- Bash(git push*)
system_prompt_file: .wave/personas/navigator.md
temperature: 0.1
philosopher:
adapter: claude
description: Architecture design and specification
model: claude-opus
permissions:
allowed_tools:
- Read
- Write
- Edit
- Bash
- Glob
- Grep
deny: []
system_prompt_file: .wave/personas/philosopher.md
temperature: 0.3
planner:
adapter: claude
description: Task breakdown and planning
model: claude-haiku
permissions:
allowed_tools:
- Read
- Write
- Edit
- Bash
- Glob
- Grep
deny: []
system_prompt_file: .wave/personas/planner.md
temperature: 0.2
provocateur:
adapter: claude
description: Creative challenger for divergent thinking and complexity hunting
model: claude-opus
permissions:
allowed_tools:
- Read
- Write
- Glob
- Grep
- Bash(wc *)
- Bash(git log*)
- Bash(git diff*)
- Bash(find*)
- Bash(ls*)
deny:
- Edit(*)
- Bash(git commit*)
- Bash(git push*)
- Bash(rm*)
system_prompt_file: .wave/personas/provocateur.md
temperature: 0.8
researcher:
adapter: claude
description: Deep codebase research and analysis
model: claude-opus
permissions:
allowed_tools:
- Read
- Write
- Edit
- Bash
- Glob
- Grep
- WebSearch
- WebFetch
deny: []
system_prompt_file: .wave/personas/researcher.md
temperature: 0.1
reviewer:
adapter: claude
description: Code review and quality checks
permissions:
allowed_tools:
- Read
- Write
- Glob
- Grep
- Bash
deny:
- Write(*.go)
- Write(*.ts)
- Write(*.py)
- Write(*.rs)
- Edit(*)
- Bash(rm *)
- Bash(git push*)
- Bash(git commit*)
system_prompt_file: .wave/personas/reviewer.md
temperature: 0.1
summarizer:
adapter: claude
description: Context compaction for relay handoffs
model: claude-haiku
permissions:
allowed_tools:
- Read
- Write
- Edit
- Bash
- Glob
- Grep
deny: []
system_prompt_file: .wave/personas/summarizer.md
temperature: 0
supervisor:
adapter: claude
description: Work supervision and quality evaluation
model: claude-opus
permissions:
allowed_tools:
- Read
- Write
- Glob
- Grep
- Bash
deny:
- Edit(*)
- Bash(git push*)
- Bash(git commit*)
- Bash(rm*)
system_prompt_file: .wave/personas/supervisor.md
temperature: 0.2
synthesizer:
adapter: claude
description: Structured synthesis of analysis findings into actionable JSON proposals
permissions:
allowed_tools:
- Read
- Write
- Edit
- Bash
- Glob
- Grep
deny: []
system_prompt_file: .wave/personas/synthesizer.md
temperature: 0.2
validator:
adapter: claude
description: Skeptical analysis and verification of findings against source code
permissions:
allowed_tools:
- Read
- Write
- Glob
- Grep
- Bash(wc *)
- Bash(git log*)
- Bash(git diff*)
deny:
- Edit(*)
- Bash(git commit*)
- Bash(git push*)
- Bash(rm*)
system_prompt_file: .wave/personas/validator.md
temperature: 0.1
project:
build_command: npm run build
flavour: node
format_command: npm run format
language: javascript
lint_command: ""
skill: javascript
source_glob: '*.{js,jsx,ts,tsx}'
test_command: npm test
runtime:
audit:
log_all_file_operations: false
log_all_tool_calls: true
log_dir: .wave/traces/
default_timeout_minutes: 30
max_concurrent_workers: 5
meta_pipeline:
max_depth: 2
max_total_steps: 20
max_total_tokens: 500000
timeout_minutes: 60
relay:
strategy: summarize_to_checkpoint
token_threshold_percent: 80
workspace_root: .wave/workspaces