30 Commits

Author SHA1 Message Date
c560676544 feat: implement #4 — replace answer-revealing validation messages with pedagogical hints
Rewrite ~120 validation error messages across 17 English lesson modules
and their localized variants (ar, de, es, pl, uk) to use concept questions,
property hints, and directional nudges instead of revealing the exact
CSS property-value answers.

Priority modules (flexbox, box-model, colors, positioning) fully rewritten.
All remaining CSS modules updated. Only message strings changed — no
validation logic modifications.
2026-03-28 19:40:28 +01:00
782e87705c docs: add spec, plan, and task breakdown for issue #4
Pedagogical validation message rewrite across 17 English lesson
modules and 5 localized variants (ar, de, es, pl, uk).
2026-03-28 19:21:24 +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
78 changed files with 4080 additions and 593 deletions

View File

@@ -1,15 +1,6 @@
{ {
"permissions": { "permissions": {
"allow": [ "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/**)"] "deny": ["Read(./.env)", "Read(./.env.*)", "Read(./secrets/**)"]
}, },

11
.gitignore vendored
View File

@@ -8,3 +8,14 @@ coverage
# Claude Code local settings (user-specific) # 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 }: outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system: flake-utils.lib.eachDefaultSystem (system:
let let
pkgs = nixpkgs.legacyPackages.${system}; pkgs = import nixpkgs { inherit system; config.allowUnfree = true; };
in { in {
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [ buildInputs = with pkgs; [
nodejs_20 nodejs_20
nodePackages.npm nodePackages.npm
gnumake gnumake
claude-code
]; ];
shellHook = '' shellHook = ''

View File

@@ -22,7 +22,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "coral" }, "value": { "property": "color", "expected": "coral" },
"message": "Add <kbd>color: coral;</kbd>" "message": "Which property controls text color?"
} }
] ]
}, },
@@ -43,12 +43,12 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "background", "expected": "lavender" }, "value": { "property": "background", "expected": "lavender" },
"message": "Add <kbd>background: lavender;</kbd>" "message": "Check the <kbd>background</kbd> property"
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "padding", "expected": "1rem" }, "value": { "property": "padding", "expected": "1rem" },
"message": "Add <kbd>padding: 1rem;</kbd>" "message": "The card needs space inside its edges"
} }
] ]
}, },
@@ -74,7 +74,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "steelblue" }, "value": { "property": "color", "expected": "steelblue" },
"message": "Set <kbd>color: steelblue</kbd>" "message": "Which property changes text color?"
} }
] ]
}, },
@@ -100,7 +100,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "coral" }, "value": { "property": "color", "expected": "coral" },
"message": "Set <kbd>color: coral</kbd>" "message": "What value gives a warm, reddish-orange color?"
} }
] ]
}, },
@@ -126,7 +126,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "background", "expected": "tomato" }, "value": { "property": "background", "expected": "tomato" },
"message": "Set <kbd>background: tomato</kbd>" "message": "The badge needs a bright red background"
} }
] ]
}, },
@@ -152,7 +152,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "background", "expected": "steelblue" }, "value": { "property": "background", "expected": "steelblue" },
"message": "Set <kbd>background: steelblue</kbd>" "message": "Which property sets the button's fill color?"
} }
] ]
}, },
@@ -178,7 +178,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "text-decoration", "expected": "none" }, "value": { "property": "text-decoration", "expected": "none" },
"message": "Set <kbd>text-decoration: none</kbd>" "message": "Which property controls the underline on links?"
} }
] ]
}, },
@@ -199,7 +199,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "steelblue" }, "value": { "property": "color", "expected": "steelblue" },
"message": "Set <kbd>color: steelblue</kbd>" "message": "Check the <kbd>color</kbd> property"
} }
] ]
}, },
@@ -225,7 +225,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "white" }, "value": { "property": "color", "expected": "white" },
"message": "Set <kbd>color: white</kbd>" "message": "The links need to stand out against the blue background"
} }
] ]
}, },
@@ -251,7 +251,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "font-size", "expected": "0.9rem" }, "value": { "property": "font-size", "expected": "0.9rem" },
"message": "Set <kbd>font-size: 0.9rem</kbd>" "message": "Check the <kbd>font-size</kbd> property — the text should be slightly smaller"
} }
] ]
} }

View File

@@ -147,7 +147,7 @@
"property": "padding", "property": "padding",
"expected": "20px" "expected": "20px"
}, },
"message": "Set the padding value to <kbd>20px</kbd>", "message": "How much breathing room does the content need? Re-read the task for the exact size",
"options": { "options": {
"exact": true "exact": true
} }
@@ -181,7 +181,7 @@
"property": "margin-bottom", "property": "margin-bottom",
"expected": "30px" "expected": "30px"
}, },
"message": "Set the margin-bottom value to <kbd>30px</kbd>", "message": "How much space should separate the title from the content below? Check the task for the amount",
"options": { "options": {
"exact": true "exact": true
} }
@@ -212,7 +212,7 @@
{ {
"type": "regex", "type": "regex",
"value": "border:\\s*2px\\s+solid\\s+blue", "value": "border:\\s*2px\\s+solid\\s+blue",
"message": "Set the border to <kbd>2px solid blue</kbd>", "message": "The <kbd>border</kbd> shorthand takes three parts: width, style, and color",
"options": { "options": {
"caseSensitive": false "caseSensitive": false
} }
@@ -246,7 +246,7 @@
"property": "justify-content", "property": "justify-content",
"expected": "center" "expected": "center"
}, },
"message": "Set <kbd>justify-content</kbd> to <kbd>center</kbd>", "message": "How do you center items along the main axis?",
"options": { "options": {
"exact": true "exact": true
} }
@@ -265,7 +265,7 @@
"property": "align-items", "property": "align-items",
"expected": "center" "expected": "center"
}, },
"message": "Set <kbd>align-items</kbd> to <kbd>center</kbd>", "message": "Which property centers items along the cross axis?",
"options": { "options": {
"exact": true "exact": true
} }
@@ -327,7 +327,7 @@
{ {
"type": "regex", "type": "regex",
"value": "font-family:\\s*Courier,\\s*monospace", "value": "font-family:\\s*Courier,\\s*monospace",
"message": "Set the font-family to <kbd>Courier, monospace</kbd>", "message": "A font stack lists preferred fonts first, followed by a generic fallback, separated by commas",
"options": { "options": {
"caseSensitive": false "caseSensitive": false
} }

View File

@@ -22,7 +22,7 @@
{ {
"type": "regex", "type": "regex",
"value": "^input\\[type=\"text\"\\]\\s*{", "value": "^input\\[type=\"text\"\\]\\s*{",
"message": "Use <kbd>input[type=\"text\"] { … }</kbd> as your attribute selector", "message": "Which attribute selector syntax targets inputs with a specific type? Check the square-bracket notation from the description.",
"options": { "options": {
"caseSensitive": true "caseSensitive": true
} }
@@ -85,7 +85,7 @@
{ {
"type": "regex", "type": "regex",
"value": "^a\\[href\\^=\"https\"\\]\\s*{", "value": "^a\\[href\\^=\"https\"\\]\\s*{",
"message": "Use <kbd>a[href^=\"https\"] { … }</kbd> as your attribute selector to target HTTPS links", "message": "Which partial-match attribute selector targets values that <em>start with</em> a given string? Combine the element name with that selector.",
"options": { "options": {
"caseSensitive": true "caseSensitive": true
} }
@@ -145,7 +145,7 @@
{ {
"type": "regex", "type": "regex",
"value": "^\\.main-nav\\s*>\\s*li\\s*{", "value": "^\\.main-nav\\s*>\\s*li\\s*{",
"message": "Use <kbd>.main-nav > li { … }</kbd> with the child combinator to target only direct children", "message": "Which combinator selects only <em>direct</em> children, skipping deeper descendants? Place it between the parent and child selectors.",
"options": { "options": {
"caseSensitive": true "caseSensitive": true
} }
@@ -203,7 +203,7 @@
{ {
"type": "regex", "type": "regex",
"value": "^nav\\s+a\\s*{", "value": "^nav\\s+a\\s*{",
"message": "Use <kbd>nav a</kbd> with a space between nav and a", "message": "The descendant combinator is the simplest one — what character separates a parent selector from a descendant selector?",
"options": { "options": {
"caseSensitive": true "caseSensitive": true
} }
@@ -261,7 +261,7 @@
{ {
"type": "regex", "type": "regex",
"value": "^h2\\s*\\+\\s*p\\s*{", "value": "^h2\\s*\\+\\s*p\\s*{",
"message": "Use <kbd>h2 + p</kbd> with the adjacent sibling combinator (+)", "message": "Which combinator targets the element <em>immediately</em> following a sibling? Place it between the two element selectors.",
"options": { "options": {
"caseSensitive": true "caseSensitive": true
} }
@@ -319,7 +319,7 @@
{ {
"type": "regex", "type": "regex",
"value": "^h3\\s*~\\s*p\\s*{", "value": "^h3\\s*~\\s*p\\s*{",
"message": "Use <kbd>h3 ~ p</kbd> with the general sibling combinator (~)", "message": "Which combinator selects <em>all</em> later siblings, not just the one right next to it? Place it between the two element selectors.",
"options": { "options": {
"caseSensitive": true "caseSensitive": true
} }
@@ -377,7 +377,7 @@
{ {
"type": "regex", "type": "regex",
"value": "^button:hover\\s*{", "value": "^button:hover\\s*{",
"message": "Use <kbd>button:hover</kbd> to target buttons on hover", "message": "Which pseudo-class activates when the cursor is over an element? Attach it to the button selector with a colon.",
"options": { "options": {
"caseSensitive": true "caseSensitive": true
} }
@@ -435,7 +435,7 @@
{ {
"type": "regex", "type": "regex",
"value": "^li:first-child\\s*{", "value": "^li:first-child\\s*{",
"message": "Use <kbd>li:first-child</kbd> to target first list items", "message": "Which pseudo-class selects an element only when it is the <em>first</em> child of its parent? Attach it to the <kbd>li</kbd> selector.",
"options": { "options": {
"caseSensitive": true "caseSensitive": true
} }

View File

@@ -22,7 +22,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "padding", "expected": "1rem" }, "value": { "property": "padding", "expected": "1rem" },
"message": "Set <kbd>padding: 1rem</kbd>" "message": "Which property adds space between an element's content and its border?"
} }
] ]
}, },
@@ -43,7 +43,7 @@
{ {
"type": "regex", "type": "regex",
"value": "border-left:\\s*4px\\s+solid\\s+steelblue", "value": "border-left:\\s*4px\\s+solid\\s+steelblue",
"message": "Set <kbd>border-left: 4px solid steelblue</kbd>", "message": "Use the <kbd>border-left</kbd> shorthand with width, style, and color values",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -65,7 +65,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "margin-bottom", "expected": "1rem" }, "value": { "property": "margin-bottom", "expected": "1rem" },
"message": "Set <kbd>margin-bottom: 1rem</kbd>" "message": "Which property creates space below an element, pushing neighbors away?"
} }
] ]
}, },
@@ -86,7 +86,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "box-sizing", "expected": "border-box" }, "value": { "property": "box-sizing", "expected": "border-box" },
"message": "Set <kbd>box-sizing: border-box</kbd>" "message": "Which <kbd>box-sizing</kbd> value includes padding and border in the element's total width?"
} }
] ]
}, },
@@ -107,7 +107,7 @@
{ {
"type": "regex", "type": "regex",
"value": "padding:\\s*8px\\s+1rem", "value": "padding:\\s*8px\\s+1rem",
"message": "Set <kbd>padding: 8px 1rem</kbd>", "message": "Use the <kbd>padding</kbd> shorthand with two values: vertical then horizontal",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -129,7 +129,7 @@
{ {
"type": "regex", "type": "regex",
"value": "margin:\\s*0\\s+auto", "value": "margin:\\s*0\\s+auto",
"message": "Set <kbd>margin: 0 auto</kbd>", "message": "Use <kbd>margin</kbd> with a keyword that auto-calculates equal left and right spacing",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -151,7 +151,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "border-radius", "expected": "50%" }, "value": { "property": "border-radius", "expected": "50%" },
"message": "Set <kbd>border-radius: 50%</kbd>" "message": "Which <kbd>border-radius</kbd> percentage creates a perfect circle from a square element?"
} }
] ]
}, },
@@ -172,18 +172,18 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "padding", "expected": "1rem" }, "value": { "property": "padding", "expected": "1rem" },
"message": "Set <kbd>padding: 1rem</kbd>" "message": "Add inner spacing to the notification card"
}, },
{ {
"type": "regex", "type": "regex",
"value": "border-left:\\s*4px\\s+solid\\s+coral", "value": "border-left:\\s*4px\\s+solid\\s+coral",
"message": "Set <kbd>border-left: 4px solid coral</kbd>", "message": "Add a left border accent using the <kbd>border-left</kbd> shorthand",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "border-radius", "expected": "4px" }, "value": { "property": "border-radius", "expected": "4px" },
"message": "Set <kbd>border-radius: 4px</kbd>" "message": "Round the corners slightly with <kbd>border-radius</kbd>"
} }
] ]
} }

View File

@@ -22,7 +22,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "background-color", "expected": "seashell" }, "value": { "property": "background-color", "expected": "seashell" },
"message": "Set <kbd>background-color: seashell</kbd>" "message": "Which property sets the fill color behind an element's content area?"
} }
] ]
}, },
@@ -43,7 +43,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "coral" }, "value": { "property": "color", "expected": "coral" },
"message": "Set <kbd>color: coral</kbd>" "message": "Which CSS property changes the color of text content?"
} }
] ]
}, },
@@ -64,7 +64,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "border-color", "expected": "coral" }, "value": { "property": "border-color", "expected": "coral" },
"message": "Set <kbd>border-color: coral</kbd>" "message": "Which property changes just the color of an existing border?"
} }
] ]
}, },
@@ -85,7 +85,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "background-color", "expected": "#ffd700" }, "value": { "property": "background-color", "expected": "#ffd700" },
"message": "Set <kbd>background-color: #ffd700</kbd>" "message": "Set the <kbd>background-color</kbd> using a hex code format"
} }
] ]
} }

View File

@@ -142,7 +142,7 @@
{ {
"type": "contains", "type": "contains",
"value": "2px 2px", "value": "2px 2px",
"message": "Set offset to <kbd>2px 2px</kbd>" "message": "How far should the shadow move horizontally and vertically?"
} }
] ]
} }

View File

@@ -22,7 +22,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "max-width", "expected": "40rem" }, "value": { "property": "max-width", "expected": "40rem" },
"message": "Set <kbd>max-width: 40rem</kbd>" "message": "Which property caps an element's width? Try a <kbd>rem</kbd> value for readable line length."
} }
] ]
}, },
@@ -71,7 +71,7 @@
{ {
"type": "regex", "type": "regex",
"value": "width:\\s*calc\\(\\s*100%\\s*-\\s*200px\\s*\\)", "value": "width:\\s*calc\\(\\s*100%\\s*-\\s*200px\\s*\\)",
"message": "Set <kbd>width: calc(100% - 200px)</kbd>", "message": "Use <kbd>calc()</kbd> to subtract the sidebar's fixed width from the full container width.",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -93,7 +93,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "min-height", "expected": "100vh" }, "value": { "property": "min-height", "expected": "100vh" },
"message": "Set <kbd>min-height: 100vh</kbd>" "message": "Which property ensures a minimum height? Use a viewport unit for full-screen coverage."
} }
] ]
} }

View File

@@ -28,7 +28,7 @@
{ {
"type": "regex", "type": "regex",
"value": "transition:\\s*background-color\\s*0\\.3s", "value": "transition:\\s*background-color\\s*0\\.3s",
"message": "Set <kbd>transition: background-color 0.3s</kbd>", "message": "Specify which property to transition and how long it should take.",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -56,7 +56,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "transition-timing-function", "expected": "ease-in-out" }, "value": { "property": "transition-timing-function", "expected": "ease-in-out" },
"message": "Set timing to <kbd>ease-in-out</kbd>" "message": "Which easing keyword starts slow, speeds up, then slows down again?"
} }
] ]
}, },
@@ -95,7 +95,7 @@
{ {
"type": "regex", "type": "regex",
"value": "animation:.*bounce.*1s.*infinite", "value": "animation:.*bounce.*1s.*infinite",
"message": "Apply <kbd>animation: bounce 1s infinite</kbd>", "message": "Use the <kbd>animation</kbd> shorthand: name, duration, and repeat count.",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -117,27 +117,27 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "animation-name", "expected": "pulse" }, "value": { "property": "animation-name", "expected": "pulse" },
"message": "Set <kbd>animation-name: pulse</kbd>" "message": "Which property links an element to a named <kbd>@keyframes</kbd> rule?"
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "animation-duration", "expected": "2s" }, "value": { "property": "animation-duration", "expected": "2s" },
"message": "Set <kbd>animation-duration: 2s</kbd>" "message": "How long should one full cycle of the animation take?"
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "animation-delay", "expected": "1s" }, "value": { "property": "animation-delay", "expected": "1s" },
"message": "Set <kbd>animation-delay: 1s</kbd>" "message": "Which property makes the animation wait before starting?"
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "animation-iteration-count", "expected": "2" }, "value": { "property": "animation-iteration-count", "expected": "2" },
"message": "Set <kbd>animation-iteration-count: 2</kbd>" "message": "Which property controls how many times the animation repeats?"
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "animation-fill-mode", "expected": "forwards" }, "value": { "property": "animation-fill-mode", "expected": "forwards" },
"message": "Set <kbd>animation-fill-mode: forwards</kbd>" "message": "Which property keeps the element styled in its final keyframe state after the animation ends?"
} }
] ]
} }

View File

@@ -18,14 +18,24 @@
"codeSuffix": "}", "codeSuffix": "}",
"previewContainer": "preview-area", "previewContainer": "preview-area",
"validations": [ "validations": [
{ "type": "contains", "value": "display", "message": "Use <kbd>display: flex</kbd>", "options": { "caseSensitive": false } }, {
"type": "contains",
"value": "display",
"message": "Which display mode arranges children in a row or column?",
"options": { "caseSensitive": false }
},
{ {
"type": "contains", "type": "contains",
"value": "justify-content", "value": "justify-content",
"message": "Use <kbd>justify-content: center</kbd>", "message": "How do you center items along the main axis?",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
}, },
{ "type": "contains", "value": "align-items", "message": "Use <kbd>align-items: center</kbd>", "options": { "caseSensitive": false } } {
"type": "contains",
"value": "align-items",
"message": "Which property centers items along the cross axis?",
"options": { "caseSensitive": false }
}
] ]
}, },
{ {
@@ -44,13 +54,13 @@
{ {
"type": "contains", "type": "contains",
"value": "flex-wrap: wrap", "value": "flex-wrap: wrap",
"message": "Use <kbd>flex-wrap: wrap</kbd>", "message": "Which property allows flex items to flow onto multiple lines?",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
}, },
{ {
"type": "regex", "type": "regex",
"value": ".item.*flex:\\s*1\\s+1\\s+100px", "value": ".item.*flex:\\s*1\\s+1\\s+100px",
"message": "Set <kbd>flex: 1 1 100px</kbd> on items", "message": "The <kbd>flex</kbd> shorthand takes grow, shrink, and basis values — what basis size should each item start from?",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -68,17 +78,22 @@
"codeSuffix": "}", "codeSuffix": "}",
"previewContainer": "preview-area", "previewContainer": "preview-area",
"validations": [ "validations": [
{ "type": "contains", "value": "display: grid", "message": "Use <kbd>display: grid</kbd>", "options": { "caseSensitive": false } }, {
"type": "contains",
"value": "display: grid",
"message": "Which display mode lets you define rows and columns?",
"options": { "caseSensitive": false }
},
{ {
"type": "contains", "type": "contains",
"value": "grid-template-columns", "value": "grid-template-columns",
"message": "Define <kbd>grid-template-columns</kbd>", "message": "Which property defines the column structure of a grid?",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
}, },
{ {
"type": "regex", "type": "regex",
"value": "grid-template-columns:\\s*repeat\\(3,\\s*1fr\\)\\s*", "value": "grid-template-columns:\\s*repeat\\(3,\\s*1fr\\)\\s*",
"message": "Create three equal columns with <kbd>repeat(3, 1fr)</kbd>", "message": "The <kbd>repeat()</kbd> function can create equal-width columns — how many do you need, and what unit makes them equal?",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
}, },
{ "type": "contains", "value": "gap", "message": "Use <kbd>gap</kbd> property", "options": { "caseSensitive": false } } { "type": "contains", "value": "gap", "message": "Use <kbd>gap</kbd> property", "options": { "caseSensitive": false } }
@@ -106,7 +121,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "grid-column", "expected": "1 / span 2" }, "value": { "property": "grid-column", "expected": "1 / span 2" },
"message": "Span across 2 columns with <kbd>grid-column: 1 / span 2</kbd>", "message": "Use <kbd>grid-column</kbd> with a start line and a span count \u2014 how many columns should this item stretch across?",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]

View File

@@ -22,7 +22,7 @@
{ {
"type": "regex", "type": "regex",
"value": "@media\\s*\\(max-width:\\s*600px\\)", "value": "@media\\s*\\(max-width:\\s*600px\\)",
"message": "Use <kbd>@media (max-width: 600px)</kbd>", "message": "Start with an <kbd>@media</kbd> rule \u2014 which condition targets screens 600px wide or smaller?",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
}, },
{ {
@@ -34,7 +34,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "background", "expected": "lightcoral" }, "value": { "property": "background", "expected": "lightcoral" },
"message": "Set <kbd>background: lightcoral</kbd>", "message": "Which property changes the element's background color?",
"options": { "exact": false } "options": { "exact": false }
} }
] ]
@@ -53,7 +53,11 @@
"solution": " font-size: 5vw;", "solution": " font-size: 5vw;",
"previewContainer": "preview-area", "previewContainer": "preview-area",
"validations": [ "validations": [
{ "type": "property_value", "value": { "property": "font-size", "expected": "5vw" }, "message": "Set <kbd>font-size: 5vw</kbd>" } {
"type": "property_value",
"value": { "property": "font-size", "expected": "5vw" },
"message": "Which CSS unit scales relative to the viewport width?"
}
] ]
}, },
{ {
@@ -73,18 +77,18 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "display", "expected": "grid" }, "value": { "property": "display", "expected": "grid" },
"message": "Set <kbd>display: grid</kbd>" "message": "Which display mode lets you define rows and columns?"
}, },
{ {
"type": "regex", "type": "regex",
"value": "repeat\\(auto-fit,\\s*minmax\\(200px,\\s*1fr\\)\\)", "value": "repeat\\(auto-fit,\\s*minmax\\(200px,\\s*1fr\\)\\)",
"message": "Use <kbd>repeat(auto-fit, minmax(200px, 1fr))</kbd>", "message": "Try <kbd>repeat()</kbd> with <kbd>auto-fit</kbd> and <kbd>minmax()</kbd> — what minimum and maximum sizes create flexible columns?",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "gap", "expected": "1rem" }, "value": { "property": "gap", "expected": "1rem" },
"message": "Set <kbd>gap: 1rem</kbd>" "message": "Which property adds space between grid items?"
} }
] ]
}, },
@@ -105,7 +109,7 @@
{ {
"type": "regex", "type": "regex",
"value": "@media\\s*\\(min-width:\\s*768px\\)", "value": "@media\\s*\\(min-width:\\s*768px\\)",
"message": "Use <kbd>@media (min-width: 768px)</kbd>", "message": "Which <kbd>@media</kbd> condition applies styles when the viewport is at least 768px wide?",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
}, },
{ {
@@ -117,7 +121,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "width", "expected": "250px" }, "value": { "property": "width", "expected": "250px" },
"message": "Set <kbd>width: 250px</kbd>", "message": "Which property controls how wide the sidebar should be on larger screens?",
"options": { "exact": false } "options": { "exact": false }
} }
] ]

View File

@@ -22,7 +22,7 @@
{ {
"type": "contains", "type": "contains",
"value": "linear-gradient", "value": "linear-gradient",
"message": "Use <kbd>linear-gradient()</kbd>" "message": "Which CSS function creates a smooth transition between colors along a straight line?"
}, },
{ {
"type": "contains", "type": "contains",
@@ -53,7 +53,7 @@
{ {
"type": "contains", "type": "contains",
"value": "to right", "value": "to right",
"message": "Add <kbd>to right</kbd> to set the direction" "message": "Which direction keyword makes a gradient flow horizontally from the left side?"
} }
] ]
}, },
@@ -74,7 +74,7 @@
{ {
"type": "contains", "type": "contains",
"value": "radial-gradient", "value": "radial-gradient",
"message": "Use <kbd>radial-gradient()</kbd>" "message": "Which CSS function creates a gradient that radiates outward from a center point?"
}, },
{ {
"type": "contains", "type": "contains",

View File

@@ -20,7 +20,7 @@
{ {
"type": "contains_class", "type": "contains_class",
"value": "bg-blue-500", "value": "bg-blue-500",
"message": "Add the <kbd>bg-blue-500</kbd> class for a blue background." "message": "Which Tailwind utility sets a blue background color? Think about the <kbd>bg-{color}-{shade}</kbd> pattern."
} }
] ]
}, },
@@ -38,22 +38,22 @@
{ {
"type": "contains_class", "type": "contains_class",
"value": "bg-white", "value": "bg-white",
"message": "Add <kbd>bg-white</kbd> to set the background color to white." "message": "Which Tailwind utility sets a white background? The pattern is <kbd>bg-{color}</kbd>."
}, },
{ {
"type": "contains_class", "type": "contains_class",
"value": "p-4", "value": "p-4",
"message": "Add <kbd>p-4</kbd> to apply 1rem padding on all sides." "message": "Which Tailwind utility adds 1rem padding on all sides? Remember: each spacing unit is 0.25rem."
}, },
{ {
"type": "contains_class", "type": "contains_class",
"value": "rounded", "value": "rounded",
"message": "Add <kbd>rounded</kbd> to apply border-radius of 0.25rem." "message": "Which Tailwind utility adds rounded corners? It is one of the simplest utility names."
}, },
{ {
"type": "contains_class", "type": "contains_class",
"value": "shadow-sm", "value": "shadow-sm",
"message": "Add <kbd>shadow-sm</kbd> to apply small drop-shadow." "message": "Which Tailwind utility adds a small drop-shadow? Look for a <kbd>shadow-</kbd> variant."
} }
] ]
}, },
@@ -71,17 +71,17 @@
{ {
"type": "contains_class", "type": "contains_class",
"value": "text-blue-600", "value": "text-blue-600",
"message": "Add <kbd>text-blue-600</kbd> to make the text blue" "message": "Which Tailwind utility controls text color? Use the <kbd>text-{color}-{shade}</kbd> pattern with a blue shade."
}, },
{ {
"type": "contains_class", "type": "contains_class",
"value": "text-2xl", "value": "text-2xl",
"message": "Add <kbd>text-2xl</kbd> to increase the font size to 1.5rem" "message": "Which Tailwind utility sets the font size to 1.5rem? Check the <kbd>text-{size}</kbd> scale."
}, },
{ {
"type": "contains_class", "type": "contains_class",
"value": "font-bold", "value": "font-bold",
"message": "Add <kbd>font-bold</kbd> to make the text bold (font-weight: 700)" "message": "Which Tailwind utility makes text bold? The <kbd>font-{weight}</kbd> pattern controls font weight."
} }
] ]
}, },
@@ -99,17 +99,17 @@
{ {
"type": "contains_class", "type": "contains_class",
"value": "px-6", "value": "px-6",
"message": "Add <kbd>px-6</kbd> for horizontal padding (1.5rem left and right)" "message": "Which Tailwind utility adds horizontal padding of 1.5rem? The <kbd>px-</kbd> prefix targets left and right."
}, },
{ {
"type": "contains_class", "type": "contains_class",
"value": "py-3", "value": "py-3",
"message": "Add <kbd>py-3</kbd> for vertical padding (0.75rem top and bottom)" "message": "Which Tailwind utility adds vertical padding of 0.75rem? The <kbd>py-</kbd> prefix targets top and bottom."
}, },
{ {
"type": "contains_class", "type": "contains_class",
"value": "mx-auto", "value": "mx-auto",
"message": "Add <kbd>mx-auto</kbd> to center the button horizontally" "message": "Which Tailwind utility centers an element horizontally using auto margins?"
} }
] ]
}, },
@@ -127,32 +127,32 @@
{ {
"type": "contains_class", "type": "contains_class",
"value": "w-full", "value": "w-full",
"message": "Add <kbd>w-full</kbd> for 100% width on mobile" "message": "Which Tailwind utility makes an element take up 100% width? This is the base (mobile) style."
}, },
{ {
"type": "contains_class", "type": "contains_class",
"value": "md:w-1/2", "value": "md:w-1/2",
"message": "Add <kbd>md:w-1/2</kbd> for 50% width on tablet and up" "message": "How do you set 50% width at the <kbd>md:</kbd> breakpoint? Tailwind uses fraction notation for widths."
}, },
{ {
"type": "contains_class", "type": "contains_class",
"value": "lg:w-1/3", "value": "lg:w-1/3",
"message": "Add <kbd>lg:w-1/3</kbd> for 33.33% width on desktop and up" "message": "How do you set one-third width at the <kbd>lg:</kbd> breakpoint? Use the same fraction pattern."
}, },
{ {
"type": "contains_class", "type": "contains_class",
"value": "text-lg", "value": "text-lg",
"message": "Add <kbd>text-lg</kbd> for the base text size" "message": "Which Tailwind text size utility is one step above the base size? Think about the <kbd>text-{size}</kbd> scale."
}, },
{ {
"type": "contains_class", "type": "contains_class",
"value": "md:text-xl", "value": "md:text-xl",
"message": "Add <kbd>md:text-xl</kbd> for larger text on tablets" "message": "How do you increase the text size at the <kbd>md:</kbd> breakpoint? Go one step larger."
}, },
{ {
"type": "contains_class", "type": "contains_class",
"value": "lg:text-2xl", "value": "lg:text-2xl",
"message": "Add <kbd>lg:text-2xl</kbd> for even larger text on desktop" "message": "How do you set an even larger text size at the <kbd>lg:</kbd> breakpoint? Continue stepping up the scale."
} }
] ]
} }

View File

@@ -22,7 +22,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "filter", "expected": "blur(4px)" }, "value": { "property": "filter", "expected": "blur(4px)" },
"message": "Set <kbd>filter: blur(4px)</kbd>" "message": "Which CSS property applies visual effects like blur? Use the <kbd>blur()</kbd> function with a pixel value."
} }
] ]
}, },
@@ -48,7 +48,7 @@
{ {
"type": "contains", "type": "contains",
"value": "100%", "value": "100%",
"message": "Set to <kbd>100%</kbd> for full grayscale" "message": "What percentage value removes all color completely?"
} }
] ]
}, },
@@ -74,7 +74,7 @@
{ {
"type": "contains", "type": "contains",
"value": "120%", "value": "120%",
"message": "Set to <kbd>120%</kbd>" "message": "What percentage makes the element slightly brighter than normal? Normal is 100%."
} }
] ]
}, },
@@ -100,7 +100,7 @@
{ {
"type": "contains", "type": "contains",
"value": "4px 4px 8px", "value": "4px 4px 8px",
"message": "Set shadow offset and blur" "message": "Set the x-offset, y-offset, and blur radius. The task describes the exact values needed."
} }
] ]
} }

View File

@@ -22,7 +22,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "position", "expected": "relative" }, "value": { "property": "position", "expected": "relative" },
"message": "Set <kbd>position: relative</kbd>" "message": "Which position value keeps an element in normal flow but allows offset adjustments?"
} }
] ]
}, },
@@ -43,7 +43,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "top", "expected": "-8px" }, "value": { "property": "top", "expected": "-8px" },
"message": "Set <kbd>top: -8px</kbd>" "message": "Which offset property moves an element upward from its current position?"
} }
] ]
}, },
@@ -64,7 +64,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "position", "expected": "absolute" }, "value": { "property": "position", "expected": "absolute" },
"message": "Set <kbd>position: absolute</kbd>" "message": "Which position value removes an element from normal flow for precise placement?"
} }
] ]
}, },
@@ -85,12 +85,12 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "top", "expected": "8px" }, "value": { "property": "top", "expected": "8px" },
"message": "Set <kbd>top: 8px</kbd>" "message": "Which offset property controls the distance from the top of the positioned ancestor?"
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "right", "expected": "8px" }, "value": { "property": "right", "expected": "8px" },
"message": "Set <kbd>right: 8px</kbd>" "message": "Which offset property controls the distance from the right edge?"
} }
] ]
} }

View File

@@ -48,7 +48,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "coral" }, "value": { "property": "color", "expected": "coral" },
"message": "Set <kbd>color: coral</kbd>" "message": "Which CSS property changes the text color of the bullet? Try a warm, pinkish-orange named color."
} }
] ]
}, },
@@ -95,17 +95,17 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "width", "expected": "40px" }, "value": { "property": "width", "expected": "40px" },
"message": "Set <kbd>width: 40px</kbd>" "message": "How wide should the decorative line be? Check the task for the pixel value."
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "height", "expected": "3px" }, "value": { "property": "height", "expected": "3px" },
"message": "Set <kbd>height: 3px</kbd>" "message": "Which CSS property controls the thickness of the line? A thin line looks best here."
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "background", "expected": "steelblue" }, "value": { "property": "background", "expected": "steelblue" },
"message": "Set <kbd>background: steelblue</kbd>" "message": "Which CSS property fills the line with color? Use a steel-toned blue named color."
} }
] ]
} }

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

@@ -22,7 +22,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "coral" }, "value": { "property": "color", "expected": "coral" },
"message": "أضف <kbd>color: coral;</kbd>" "message": "ما الخاصية التي تتحكم في لون النص؟"
} }
] ]
}, },
@@ -43,12 +43,12 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "background", "expected": "lavender" }, "value": { "property": "background", "expected": "lavender" },
"message": "أضف <kbd>background: lavender;</kbd>" "message": "تحقق من خاصية <kbd>background</kbd>"
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "padding", "expected": "1rem" }, "value": { "property": "padding", "expected": "1rem" },
"message": "أضف <kbd>padding: 1rem;</kbd>" "message": "البطاقة تحتاج إلى مساحة داخل حوافها"
} }
] ]
}, },
@@ -74,7 +74,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "steelblue" }, "value": { "property": "color", "expected": "steelblue" },
"message": "اضبط <kbd>color: steelblue</kbd>" "message": "ما الخاصية التي تغيّر لون النص؟"
} }
] ]
}, },
@@ -100,7 +100,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "coral" }, "value": { "property": "color", "expected": "coral" },
"message": "اضبط <kbd>color: coral</kbd>" "message": "ما القيمة التي تعطي لوناً دافئاً برتقالياً محمراً؟"
} }
] ]
}, },
@@ -126,7 +126,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "background", "expected": "tomato" }, "value": { "property": "background", "expected": "tomato" },
"message": "اضبط <kbd>background: tomato</kbd>" "message": "الشارة تحتاج إلى خلفية حمراء زاهية"
} }
] ]
}, },
@@ -152,7 +152,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "background", "expected": "steelblue" }, "value": { "property": "background", "expected": "steelblue" },
"message": "اضبط <kbd>background: steelblue</kbd>" "message": "ما الخاصية التي تضبط لون تعبئة الزر؟"
} }
] ]
}, },
@@ -178,7 +178,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "text-decoration", "expected": "none" }, "value": { "property": "text-decoration", "expected": "none" },
"message": "اضبط <kbd>text-decoration: none</kbd>" "message": "ما الخاصية التي تتحكم في الخط أسفل الروابط؟"
} }
] ]
}, },
@@ -199,7 +199,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "steelblue" }, "value": { "property": "color", "expected": "steelblue" },
"message": "اضبط <kbd>color: steelblue</kbd>" "message": "تحقق من خاصية <kbd>color</kbd>"
} }
] ]
}, },
@@ -225,7 +225,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "white" }, "value": { "property": "color", "expected": "white" },
"message": "اضبط <kbd>color: white</kbd>" "message": "الروابط تحتاج إلى أن تبرز على الخلفية الزرقاء"
} }
] ]
}, },
@@ -251,7 +251,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "font-size", "expected": "0.9rem" }, "value": { "property": "font-size", "expected": "0.9rem" },
"message": "اضبط <kbd>font-size: 0.9rem</kbd>" "message": "تحقق من خاصية <kbd>font-size</kbd> — النص يجب أن يكون أصغر قليلاً"
} }
] ]
} }

View File

@@ -22,7 +22,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "padding", "expected": "1rem" }, "value": { "property": "padding", "expected": "1rem" },
"message": "اضبط <kbd>padding: 1rem</kbd>" "message": "ما الخاصية التي تضيف مساحة بين محتوى العنصر وحدوده؟"
} }
] ]
}, },
@@ -43,7 +43,7 @@
{ {
"type": "regex", "type": "regex",
"value": "border-left:\\s*4px\\s+solid\\s+steelblue", "value": "border-left:\\s*4px\\s+solid\\s+steelblue",
"message": "اضبط <kbd>border-left: 4px solid steelblue</kbd>", "message": "استخدم اختصار <kbd>border-left</kbd> مع قيم العرض والنمط واللون",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -65,7 +65,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "margin-bottom", "expected": "1rem" }, "value": { "property": "margin-bottom", "expected": "1rem" },
"message": "اضبط <kbd>margin-bottom: 1rem</kbd>" "message": "ما الخاصية التي تُنشئ مساحة أسفل العنصر وتدفع الجيران بعيداً؟"
} }
] ]
}, },
@@ -86,7 +86,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "box-sizing", "expected": "border-box" }, "value": { "property": "box-sizing", "expected": "border-box" },
"message": "اضبط <kbd>box-sizing: border-box</kbd>" "message": "ما قيمة <kbd>box-sizing</kbd> التي تشمل الحشو والحدود في العرض الإجمالي للعنصر؟"
} }
] ]
}, },
@@ -107,7 +107,7 @@
{ {
"type": "regex", "type": "regex",
"value": "padding:\\s*8px\\s+1rem", "value": "padding:\\s*8px\\s+1rem",
"message": "اضبط <kbd>padding: 8px 1rem</kbd>", "message": "استخدم اختصار <kbd>padding</kbd> بقيمتين: عمودي ثم أفقي",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -129,7 +129,7 @@
{ {
"type": "regex", "type": "regex",
"value": "margin:\\s*0\\s+auto", "value": "margin:\\s*0\\s+auto",
"message": "اضبط <kbd>margin: 0 auto</kbd>", "message": "استخدم <kbd>margin</kbd> مع كلمة مفتاحية تحسب تلقائياً مسافات متساوية يميناً ويساراً",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -151,7 +151,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "border-radius", "expected": "50%" }, "value": { "property": "border-radius", "expected": "50%" },
"message": "اضبط <kbd>border-radius: 50%</kbd>" "message": "ما نسبة <kbd>border-radius</kbd> التي تُنشئ دائرة كاملة من عنصر مربع؟"
} }
] ]
}, },
@@ -172,18 +172,18 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "padding", "expected": "1rem" }, "value": { "property": "padding", "expected": "1rem" },
"message": "اضبط <kbd>padding: 1rem</kbd>" "message": "أضف مساحة داخلية لبطاقة الإشعار"
}, },
{ {
"type": "regex", "type": "regex",
"value": "border-left:\\s*4px\\s+solid\\s+coral", "value": "border-left:\\s*4px\\s+solid\\s+coral",
"message": "اضبط <kbd>border-left: 4px solid coral</kbd>", "message": "أضف لمسة حدود يسارية باستخدام اختصار <kbd>border-left</kbd>",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "border-radius", "expected": "4px" }, "value": { "property": "border-radius", "expected": "4px" },
"message": "اضبط <kbd>border-radius: 4px</kbd>" "message": "دوّر الزوايا قليلاً باستخدام <kbd>border-radius</kbd>"
} }
] ]
} }

View File

@@ -22,7 +22,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "max-width", "expected": "40rem" }, "value": { "property": "max-width", "expected": "40rem" },
"message": "اضبط <kbd>max-width: 40rem</kbd>" "message": "ما الخاصية التي تحدّ من عرض العنصر؟ جرّب قيمة بوحدة <kbd>rem</kbd> لطول سطر مقروء."
} }
] ]
}, },
@@ -43,13 +43,13 @@
{ {
"type": "contains", "type": "contains",
"value": "--brand", "value": "--brand",
"message": "عرّف المتغير <kbd>--brand</kbd>", "message": "عرّف متغير <kbd>--brand</kbd>",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
}, },
{ {
"type": "contains", "type": "contains",
"value": "steelblue", "value": "steelblue",
"message": "اضبط القيمة على <kbd>steelblue</kbd>", "message": "اضبط القيمة إلى <kbd>steelblue</kbd>",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -71,7 +71,7 @@
{ {
"type": "regex", "type": "regex",
"value": "width:\\s*calc\\(\\s*100%\\s*-\\s*200px\\s*\\)", "value": "width:\\s*calc\\(\\s*100%\\s*-\\s*200px\\s*\\)",
"message": "اضبط <kbd>width: calc(100% - 200px)</kbd>", "message": "استخدم <kbd>calc()</kbd> لطرح عرض الشريط الجانبي الثابت من عرض الحاوية الكامل.",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -93,7 +93,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "min-height", "expected": "100vh" }, "value": { "property": "min-height", "expected": "100vh" },
"message": "اضبط <kbd>min-height: 100vh</kbd>" "message": "ما الخاصية التي تضمن حداً أدنى للارتفاع؟ استخدم وحدة viewport لتغطية الشاشة بالكامل."
} }
] ]
} }

View File

@@ -28,7 +28,7 @@
{ {
"type": "regex", "type": "regex",
"value": "transition:\\s*background-color\\s*0\\.3s", "value": "transition:\\s*background-color\\s*0\\.3s",
"message": "اضبط <kbd>transition: background-color 0.3s</kbd>", "message": "حدد أي خاصية تريد تحريكها وكم من الوقت يجب أن تستغرق.",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -56,7 +56,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "transition-timing-function", "expected": "ease-in-out" }, "value": { "property": "transition-timing-function", "expected": "ease-in-out" },
"message": "اضبط التوقيت على <kbd>ease-in-out</kbd>" "message": "ما كلمة التسهيل التي تبدأ بطيئة، تتسارع، ثم تبطئ مرة أخرى؟"
} }
] ]
}, },
@@ -95,7 +95,7 @@
{ {
"type": "regex", "type": "regex",
"value": "animation:.*bounce.*1s.*infinite", "value": "animation:.*bounce.*1s.*infinite",
"message": "طبّق <kbd>animation: bounce 1s infinite</kbd>", "message": "استخدم اختصار <kbd>animation</kbd>: الاسم، المدة، وعدد التكرار.",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -117,27 +117,27 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "animation-name", "expected": "pulse" }, "value": { "property": "animation-name", "expected": "pulse" },
"message": "اضبط <kbd>animation-name: pulse</kbd>" "message": "ما الخاصية التي تربط العنصر بقاعدة <kbd>@keyframes</kbd> مسماة؟"
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "animation-duration", "expected": "2s" }, "value": { "property": "animation-duration", "expected": "2s" },
"message": "اضبط <kbd>animation-duration: 2s</kbd>" "message": "كم يجب أن تستغرق دورة كاملة من الحركة؟"
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "animation-delay", "expected": "1s" }, "value": { "property": "animation-delay", "expected": "1s" },
"message": "اضبط <kbd>animation-delay: 1s</kbd>" "message": "ما الخاصية التي تجعل الحركة تنتظر قبل أن تبدأ؟"
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "animation-iteration-count", "expected": "2" }, "value": { "property": "animation-iteration-count", "expected": "2" },
"message": "اضبط <kbd>animation-iteration-count: 2</kbd>" "message": "ما الخاصية التي تتحكم في عدد مرات تكرار الحركة؟"
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "animation-fill-mode", "expected": "forwards" }, "value": { "property": "animation-fill-mode", "expected": "forwards" },
"message": "اضبط <kbd>animation-fill-mode: forwards</kbd>" "message": "ما الخاصية التي تُبقي العنصر بتنسيق حالته النهائية بعد انتهاء الحركة؟"
} }
] ]
} }

View File

@@ -22,7 +22,7 @@
{ {
"type": "regex", "type": "regex",
"value": "@media\\s*\\(max-width:\\s*600px\\)", "value": "@media\\s*\\(max-width:\\s*600px\\)",
"message": "استخدم <kbd>@media (max-width: 600px)</kbd>", "message": "ابدأ بقاعدة <kbd>@media</kbd> — ما الشرط الذي يستهدف الشاشات بعرض 600px أو أقل؟",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
}, },
{ {
@@ -34,7 +34,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "background", "expected": "lightcoral" }, "value": { "property": "background", "expected": "lightcoral" },
"message": "اضبط <kbd>background: lightcoral</kbd>", "message": "ما الخاصية التي تغيّر لون خلفية العنصر؟",
"options": { "exact": false } "options": { "exact": false }
} }
] ]
@@ -53,7 +53,11 @@
"solution": " font-size: 5vw;", "solution": " font-size: 5vw;",
"previewContainer": "preview-area", "previewContainer": "preview-area",
"validations": [ "validations": [
{ "type": "property_value", "value": { "property": "font-size", "expected": "5vw" }, "message": "اضبط <kbd>font-size: 5vw</kbd>" } {
"type": "property_value",
"value": { "property": "font-size", "expected": "5vw" },
"message": "ما وحدة CSS التي تتناسب مع عرض viewport؟"
}
] ]
}, },
{ {
@@ -73,18 +77,18 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "display", "expected": "grid" }, "value": { "property": "display", "expected": "grid" },
"message": "اضبط <kbd>display: grid</kbd>" "message": "ما وضع العرض الذي يتيح لك تعريف صفوف وأعمدة؟"
}, },
{ {
"type": "regex", "type": "regex",
"value": "repeat\\(auto-fit,\\s*minmax\\(200px,\\s*1fr\\)\\)", "value": "repeat\\(auto-fit,\\s*minmax\\(200px,\\s*1fr\\)\\)",
"message": "استخدم <kbd>repeat(auto-fit, minmax(200px, 1fr))</kbd>", "message": "جرّب <kbd>repeat()</kbd> مع <kbd>auto-fit</kbd> و <kbd>minmax()</kbd> — ما الحد الأدنى والأقصى للحجم لإنشاء أعمدة مرنة؟",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "gap", "expected": "1rem" }, "value": { "property": "gap", "expected": "1rem" },
"message": "اضبط <kbd>gap: 1rem</kbd>" "message": "ما الخاصية التي تضيف مساحة بين عناصر الشبكة؟"
} }
] ]
}, },
@@ -105,7 +109,7 @@
{ {
"type": "regex", "type": "regex",
"value": "@media\\s*\\(min-width:\\s*768px\\)", "value": "@media\\s*\\(min-width:\\s*768px\\)",
"message": "استخدم <kbd>@media (min-width: 768px)</kbd>", "message": "ما شرط <kbd>@media</kbd> الذي يُطبّق الأنماط عندما يكون عرض viewport على الأقل 768px؟",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
}, },
{ {
@@ -117,7 +121,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "width", "expected": "250px" }, "value": { "property": "width", "expected": "250px" },
"message": "اضبط <kbd>width: 250px</kbd>", "message": "ما الخاصية التي تتحكم في عرض الشريط الجانبي على الشاشات الكبيرة؟",
"options": { "exact": false } "options": { "exact": false }
} }
] ]

View File

@@ -22,7 +22,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "display", "expected": "flex" }, "value": { "property": "display", "expected": "flex" },
"message": "اضبط <kbd>display: flex</kbd>" "message": "ما قيمة display التي تحوّل العنصر إلى حاوية صندوق مرن؟"
} }
] ]
}, },
@@ -43,7 +43,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "gap", "expected": "1rem" }, "value": { "property": "gap", "expected": "1rem" },
"message": "اضبط <kbd>gap: 1rem</kbd>" "message": "ما الخاصية التي تُنشئ تباعداً بين عناصر flex بدون استخدام الهوامش؟"
} }
] ]
}, },
@@ -64,7 +64,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "justify-content", "expected": "space-between" }, "value": { "property": "justify-content", "expected": "space-between" },
"message": "اضبط <kbd>justify-content: space-between</kbd>" "message": "ما قيمة <kbd>justify-content</kbd> التي تدفع العنصر الأول والأخير إلى الحواف المتقابلة؟"
} }
] ]
}, },
@@ -85,7 +85,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "align-items", "expected": "center" }, "value": { "property": "align-items", "expected": "center" },
"message": "اضبط <kbd>align-items: center</kbd>" "message": "ما الخاصية التي تُحاذي عناصر flex على طول المحور المتقاطع؟"
} }
] ]
}, },
@@ -106,7 +106,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "flex-wrap", "expected": "wrap" }, "value": { "property": "flex-wrap", "expected": "wrap" },
"message": "اضبط <kbd>flex-wrap: wrap</kbd>" "message": "ما الخاصية التي تسمح لعناصر flex بالتدفق إلى أسطر متعددة؟"
} }
] ]
}, },
@@ -127,7 +127,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "flex", "expected": "1" }, "value": { "property": "flex", "expected": "1" },
"message": "اضبط <kbd>flex: 1</kbd>" "message": "ما الخاصية التي تجعل عنصر flex ينمو لملء المساحة المتبقية؟"
} }
] ]
} }

View File

@@ -22,7 +22,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "coral" }, "value": { "property": "color", "expected": "coral" },
"message": "Füge <kbd>color: coral;</kbd> hinzu" "message": "Welche Eigenschaft ändert die Textfarbe?"
} }
] ]
}, },
@@ -43,12 +43,12 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "background", "expected": "lavender" }, "value": { "property": "background", "expected": "lavender" },
"message": "Füge <kbd>background: lavender;</kbd> hinzu" "message": "Welche Eigenschaft steuert die Hintergrundfarbe?"
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "padding", "expected": "1rem" }, "value": { "property": "padding", "expected": "1rem" },
"message": "Füge <kbd>padding: 1rem;</kbd> hinzu" "message": "Das Element benötigt auch Innenabstand -- überprüfe die <kbd>padding</kbd>-Eigenschaft"
} }
] ]
}, },
@@ -74,7 +74,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "steelblue" }, "value": { "property": "color", "expected": "steelblue" },
"message": "Setze <kbd>color: steelblue</kbd>" "message": "Überprüfe die <kbd>color</kbd>-Eigenschaft -- welcher Farbwert wurde in der Beschreibung genannt?"
} }
] ]
}, },
@@ -100,7 +100,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "coral" }, "value": { "property": "color", "expected": "coral" },
"message": "Setze <kbd>color: coral</kbd>" "message": "Überprüfe die <kbd>color</kbd>-Eigenschaft -- welche Farbe sollen die Links haben?"
} }
] ]
}, },
@@ -126,7 +126,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "background", "expected": "tomato" }, "value": { "property": "background", "expected": "tomato" },
"message": "Setze <kbd>background: tomato</kbd>" "message": "Überprüfe die <kbd>background</kbd>-Eigenschaft -- welche Farbe soll das Badge haben?"
} }
] ]
}, },
@@ -152,7 +152,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "background", "expected": "steelblue" }, "value": { "property": "background", "expected": "steelblue" },
"message": "Setze <kbd>background: steelblue</kbd>" "message": "Überprüfe die <kbd>background</kbd>-Eigenschaft -- welche Farbe soll der primäre Button haben?"
} }
] ]
}, },
@@ -178,7 +178,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "text-decoration", "expected": "none" }, "value": { "property": "text-decoration", "expected": "none" },
"message": "Setze <kbd>text-decoration: none</kbd>" "message": "Welche Eigenschaft entfernt die Unterstreichung von Links?"
} }
] ]
}, },
@@ -199,7 +199,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "steelblue" }, "value": { "property": "color", "expected": "steelblue" },
"message": "Setze <kbd>color: steelblue</kbd>" "message": "Welche Eigenschaft ändert die Textfarbe der Überschriften?"
} }
] ]
}, },
@@ -225,7 +225,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "white" }, "value": { "property": "color", "expected": "white" },
"message": "Setze <kbd>color: white</kbd>" "message": "Überprüfe die <kbd>color</kbd>-Eigenschaft -- welche Farbe passt zu einem dunklen Hintergrund?"
} }
] ]
}, },
@@ -251,7 +251,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "font-size", "expected": "0.9rem" }, "value": { "property": "font-size", "expected": "0.9rem" },
"message": "Setze <kbd>font-size: 0.9rem</kbd>" "message": "Welche Eigenschaft steuert die Schriftgröße?"
} }
] ]
} }

View File

@@ -38,7 +38,7 @@
"property": "background-color", "property": "background-color",
"expected": "lightblue" "expected": "lightblue"
}, },
"message": "Setze die Hintergrundfarbe auf <kbd>lightblue</kbd>" "message": "Überprüfe die <kbd>background-color</kbd>-Eigenschaft -- welche Farbe sollen die Text-Eingabefelder haben?"
}, },
{ {
"type": "regex", "type": "regex",
@@ -56,7 +56,7 @@
"property": "border", "property": "border",
"expected": "2px solid blue" "expected": "2px solid blue"
}, },
"message": "Setze den Rahmen auf <kbd>2px solid blue</kbd>" "message": "Das Element benötigt einen Rahmen -- überprüfe die <kbd>border</kbd>-Eigenschaft"
}, },
{ {
"type": "regex", "type": "regex",
@@ -101,7 +101,7 @@
"property": "color", "property": "color",
"expected": "green" "expected": "green"
}, },
"message": "Setze die Textfarbe auf <kbd>green</kbd>" "message": "Überprüfe die <kbd>color</kbd>-Eigenschaft -- welche Farbe kennzeichnet sichere Links?"
}, },
{ {
"type": "contains", "type": "contains",
@@ -114,7 +114,7 @@
"property": "text-decoration", "property": "text-decoration",
"expected": "underline" "expected": "underline"
}, },
"message": "Setze text-decoration auf <kbd>underline</kbd>, um HTTPS-Links zu unterstreichen" "message": "Welcher <kbd>text-decoration</kbd>-Wert macht Links visuell hervorgehoben?"
}, },
{ {
"type": "regex", "type": "regex",
@@ -159,7 +159,7 @@
"property": "background-color", "property": "background-color",
"expected": "cornflowerblue" "expected": "cornflowerblue"
}, },
"message": "Setze background-color auf <kbd>cornflowerblue</kbd> für das Hauptmenü-Styling" "message": "Überprüfe die <kbd>background-color</kbd>-Eigenschaft für die Hauptmenüpunkte"
}, },
{ {
"type": "contains", "type": "contains",
@@ -172,7 +172,7 @@
"property": "color", "property": "color",
"expected": "white" "expected": "white"
}, },
"message": "Setze die Textfarbe auf <kbd>white</kbd> für Kontrast gegen den blauen Hintergrund" "message": "Welche Textfarbe sorgt für guten Kontrast auf einem blauen Hintergrund?"
}, },
{ {
"type": "regex", "type": "regex",
@@ -217,7 +217,7 @@
"property": "text-decoration", "property": "text-decoration",
"expected": "none" "expected": "none"
}, },
"message": "Setze text-decoration auf <kbd>none</kbd>" "message": "Welcher <kbd>text-decoration</kbd>-Wert entfernt die Unterstreichung?"
}, },
{ {
"type": "contains", "type": "contains",
@@ -230,7 +230,7 @@
"property": "color", "property": "color",
"expected": "blue" "expected": "blue"
}, },
"message": "Setze color auf <kbd>blue</kbd>" "message": "Überprüfe die <kbd>color</kbd>-Eigenschaft für die Links"
}, },
{ {
"type": "regex", "type": "regex",
@@ -275,7 +275,7 @@
"property": "margin-top", "property": "margin-top",
"expected": "0" "expected": "0"
}, },
"message": "Setze margin-top auf <kbd>0</kbd>" "message": "Welcher Wert bei <kbd>margin-top</kbd> entfernt den oberen Abstand?"
}, },
{ {
"type": "contains", "type": "contains",
@@ -288,7 +288,7 @@
"property": "font-style", "property": "font-style",
"expected": "italic" "expected": "italic"
}, },
"message": "Setze font-style auf <kbd>italic</kbd>" "message": "Welcher <kbd>font-style</kbd>-Wert macht den Text kursiv?"
}, },
{ {
"type": "regex", "type": "regex",
@@ -333,7 +333,7 @@
"property": "color", "property": "color",
"expected": "gray" "expected": "gray"
}, },
"message": "Setze color auf <kbd>gray</kbd>" "message": "Überprüfe die <kbd>color</kbd>-Eigenschaft -- welche Farbe sollen die Absätze haben?"
}, },
{ {
"type": "contains", "type": "contains",
@@ -346,7 +346,7 @@
"property": "padding-left", "property": "padding-left",
"expected": "20px" "expected": "20px"
}, },
"message": "Setze padding-left auf <kbd>20px</kbd>" "message": "Das Element benötigt eine Einrückung -- überprüfe die <kbd>padding-left</kbd>-Eigenschaft"
}, },
{ {
"type": "regex", "type": "regex",
@@ -391,7 +391,7 @@
"property": "background-color", "property": "background-color",
"expected": "darkblue" "expected": "darkblue"
}, },
"message": "Setze background-color auf <kbd>darkblue</kbd>" "message": "Welche Hintergrundfarbe soll der Button beim Hover haben?"
}, },
{ {
"type": "contains", "type": "contains",
@@ -404,7 +404,7 @@
"property": "color", "property": "color",
"expected": "white" "expected": "white"
}, },
"message": "Setze color auf <kbd>white</kbd>" "message": "Welche Textfarbe sorgt für Kontrast auf dunklem Hintergrund?"
}, },
{ {
"type": "regex", "type": "regex",
@@ -449,7 +449,7 @@
"property": "font-weight", "property": "font-weight",
"expected": "bold" "expected": "bold"
}, },
"message": "Setze font-weight auf <kbd>bold</kbd>" "message": "Welcher <kbd>font-weight</kbd>-Wert macht Text fett?"
}, },
{ {
"type": "contains", "type": "contains",
@@ -462,7 +462,7 @@
"property": "margin-top", "property": "margin-top",
"expected": "0" "expected": "0"
}, },
"message": "Setze margin-top auf <kbd>0</kbd>" "message": "Welcher Wert entfernt den oberen Abstand vollständig?"
}, },
{ {
"type": "regex", "type": "regex",

View File

@@ -22,7 +22,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "padding", "expected": "1rem" }, "value": { "property": "padding", "expected": "1rem" },
"message": "Setze <kbd>padding: 1rem</kbd>" "message": "Welche Eigenschaft steuert den Innenabstand zwischen Inhalt und Rahmen?"
} }
] ]
}, },
@@ -43,7 +43,7 @@
{ {
"type": "regex", "type": "regex",
"value": "border-left:\\s*4px\\s+solid\\s+steelblue", "value": "border-left:\\s*4px\\s+solid\\s+steelblue",
"message": "Setze <kbd>border-left: 4px solid steelblue</kbd>", "message": "Überprüfe die <kbd>border-left</kbd>-Eigenschaft -- welche drei Werte braucht sie?",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -65,7 +65,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "margin-bottom", "expected": "1rem" }, "value": { "property": "margin-bottom", "expected": "1rem" },
"message": "Setze <kbd>margin-bottom: 1rem</kbd>" "message": "Welche Eigenschaft steuert den Außenabstand nach unten?"
} }
] ]
}, },
@@ -86,7 +86,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "box-sizing", "expected": "border-box" }, "value": { "property": "box-sizing", "expected": "border-box" },
"message": "Setze <kbd>box-sizing: border-box</kbd>" "message": "Welcher <kbd>box-sizing</kbd>-Wert bezieht Padding und Rahmen in die Breite ein?"
} }
] ]
}, },
@@ -107,7 +107,7 @@
{ {
"type": "regex", "type": "regex",
"value": "padding:\\s*8px\\s+1rem", "value": "padding:\\s*8px\\s+1rem",
"message": "Setze <kbd>padding: 8px 1rem</kbd>", "message": "Überprüfe die <kbd>padding</kbd>-Kurzschreibweise -- zwei Werte setzen vertikal und horizontal",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -129,7 +129,7 @@
{ {
"type": "regex", "type": "regex",
"value": "margin:\\s*0\\s+auto", "value": "margin:\\s*0\\s+auto",
"message": "Setze <kbd>margin: 0 auto</kbd>", "message": "Welche <kbd>margin</kbd>-Kurzschreibweise zentriert ein Block-Element horizontal?",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -151,7 +151,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "border-radius", "expected": "50%" }, "value": { "property": "border-radius", "expected": "50%" },
"message": "Setze <kbd>border-radius: 50%</kbd>" "message": "Welcher <kbd>border-radius</kbd>-Wert macht ein quadratisches Element rund?"
} }
] ]
}, },
@@ -172,18 +172,18 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "padding", "expected": "1rem" }, "value": { "property": "padding", "expected": "1rem" },
"message": "Setze <kbd>padding: 1rem</kbd>" "message": "Das Element benötigt Innenabstand -- überprüfe die <kbd>padding</kbd>-Eigenschaft"
}, },
{ {
"type": "regex", "type": "regex",
"value": "border-left:\\s*4px\\s+solid\\s+coral", "value": "border-left:\\s*4px\\s+solid\\s+coral",
"message": "Setze <kbd>border-left: 4px solid coral</kbd>", "message": "Überprüfe die <kbd>border-left</kbd>-Eigenschaft -- sie braucht Breite, Stil und Farbe",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "border-radius", "expected": "4px" }, "value": { "property": "border-radius", "expected": "4px" },
"message": "Setze <kbd>border-radius: 4px</kbd>" "message": "Das Element benötigt abgerundete Ecken -- überprüfe die <kbd>border-radius</kbd>-Eigenschaft"
} }
] ]
} }

View File

@@ -22,7 +22,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "max-width", "expected": "40rem" }, "value": { "property": "max-width", "expected": "40rem" },
"message": "Setze <kbd>max-width: 40rem</kbd>" "message": "Welche Eigenschaft begrenzt die maximale Breite eines Elements?"
} }
] ]
}, },
@@ -49,7 +49,7 @@
{ {
"type": "contains", "type": "contains",
"value": "steelblue", "value": "steelblue",
"message": "Setze den Wert auf <kbd>steelblue</kbd>", "message": "Welche Farbe soll die Variable haben?",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -71,7 +71,7 @@
{ {
"type": "regex", "type": "regex",
"value": "width:\\s*calc\\(\\s*100%\\s*-\\s*200px\\s*\\)", "value": "width:\\s*calc\\(\\s*100%\\s*-\\s*200px\\s*\\)",
"message": "Setze <kbd>width: calc(100% - 200px)</kbd>", "message": "Überprüfe die <kbd>width</kbd>-Eigenschaft -- wie berechnest du den verbleibenden Platz nach der Sidebar?",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -93,7 +93,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "min-height", "expected": "100vh" }, "value": { "property": "min-height", "expected": "100vh" },
"message": "Setze <kbd>min-height: 100vh</kbd>" "message": "Welche Eigenschaft setzt die Mindesthöhe? Welche Viewport-Einheit entspricht 100% der Fensterhöhe?"
} }
] ]
} }

View File

@@ -28,7 +28,7 @@
{ {
"type": "regex", "type": "regex",
"value": "transition:\\s*background-color\\s*0\\.3s", "value": "transition:\\s*background-color\\s*0\\.3s",
"message": "Setze <kbd>transition: background-color 0.3s</kbd>", "message": "Überprüfe die <kbd>transition</kbd>-Eigenschaft -- welche CSS-Eigenschaft soll sanft übergehen und wie lange?",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -56,7 +56,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "transition-timing-function", "expected": "ease-in-out" }, "value": { "property": "transition-timing-function", "expected": "ease-in-out" },
"message": "Setze timing auf <kbd>ease-in-out</kbd>" "message": "Welche Timing-Funktion startet und endet langsam?"
} }
] ]
}, },
@@ -117,27 +117,27 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "animation-name", "expected": "pulse" }, "value": { "property": "animation-name", "expected": "pulse" },
"message": "Setze <kbd>animation-name: pulse</kbd>" "message": "Welche Animation soll angewendet werden? Überprüfe den <kbd>@keyframes</kbd>-Namen."
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "animation-duration", "expected": "2s" }, "value": { "property": "animation-duration", "expected": "2s" },
"message": "Setze <kbd>animation-duration: 2s</kbd>" "message": "Welche Eigenschaft steuert die Dauer der Animation?"
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "animation-delay", "expected": "1s" }, "value": { "property": "animation-delay", "expected": "1s" },
"message": "Setze <kbd>animation-delay: 1s</kbd>" "message": "Welche Eigenschaft verzögert den Start der Animation?"
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "animation-iteration-count", "expected": "2" }, "value": { "property": "animation-iteration-count", "expected": "2" },
"message": "Setze <kbd>animation-iteration-count: 2</kbd>" "message": "Welche Eigenschaft steuert, wie oft die Animation wiederholt wird?"
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "animation-fill-mode", "expected": "forwards" }, "value": { "property": "animation-fill-mode", "expected": "forwards" },
"message": "Setze <kbd>animation-fill-mode: forwards</kbd>" "message": "Welcher <kbd>animation-fill-mode</kbd>-Wert behält den Endzustand bei?"
} }
] ]
} }

View File

@@ -34,7 +34,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "background", "expected": "lightcoral" }, "value": { "property": "background", "expected": "lightcoral" },
"message": "Setze <kbd>background: lightcoral</kbd>", "message": "Überprüfe die <kbd>background</kbd>-Eigenschaft innerhalb der Media Query",
"options": { "exact": false } "options": { "exact": false }
} }
] ]
@@ -53,7 +53,11 @@
"solution": " font-size: 5vw;", "solution": " font-size: 5vw;",
"previewContainer": "preview-area", "previewContainer": "preview-area",
"validations": [ "validations": [
{ "type": "property_value", "value": { "property": "font-size", "expected": "5vw" }, "message": "Setze <kbd>font-size: 5vw</kbd>" } {
"type": "property_value",
"value": { "property": "font-size", "expected": "5vw" },
"message": "Welche Eigenschaft steuert die Schriftgröße? Welche Viewport-Einheit skaliert mit der Breite?"
}
] ]
}, },
{ {
@@ -73,7 +77,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "display", "expected": "grid" }, "value": { "property": "display", "expected": "grid" },
"message": "Setze <kbd>display: grid</kbd>" "message": "Welcher Display-Wert aktiviert das CSS-Grid-Layout?"
}, },
{ {
"type": "regex", "type": "regex",
@@ -84,7 +88,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "gap", "expected": "1rem" }, "value": { "property": "gap", "expected": "1rem" },
"message": "Setze <kbd>gap: 1rem</kbd>" "message": "Welche Eigenschaft steuert den Abstand zwischen Grid-Zellen?"
} }
] ]
}, },
@@ -117,7 +121,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "width", "expected": "250px" }, "value": { "property": "width", "expected": "250px" },
"message": "Setze <kbd>width: 250px</kbd>", "message": "Überprüfe die <kbd>width</kbd>-Eigenschaft für die Sidebar",
"options": { "exact": false } "options": { "exact": false }
} }
] ]

View File

@@ -20,7 +20,7 @@
{ {
"type": "contains_class", "type": "contains_class",
"value": "bg-blue-500", "value": "bg-blue-500",
"message": "Füge die <kbd>bg-blue-500</kbd>-Klasse für einen blauen Hintergrund hinzu." "message": "Welche Tailwind-Klasse setzt eine blaue Hintergrundfarbe? Denke an das <kbd>bg-{farbe}-{abstufung}</kbd>-Muster."
} }
] ]
}, },
@@ -38,22 +38,22 @@
{ {
"type": "contains_class", "type": "contains_class",
"value": "bg-white", "value": "bg-white",
"message": "Füge <kbd>bg-white</kbd> hinzu, um die Hintergrundfarbe auf weiß zu setzen." "message": "Das Element benötigt einen weißen Hintergrund -- welches <kbd>bg-</kbd>-Utility passt?"
}, },
{ {
"type": "contains_class", "type": "contains_class",
"value": "p-4", "value": "p-4",
"message": "Füge <kbd>p-4</kbd> hinzu, um 1rem Padding auf allen Seiten anzuwenden." "message": "Welches <kbd>p-</kbd>-Utility erzeugt 1rem Padding auf allen Seiten?"
}, },
{ {
"type": "contains_class", "type": "contains_class",
"value": "rounded", "value": "rounded",
"message": "Füge <kbd>rounded</kbd> hinzu, um einen border-radius von 0.25rem anzuwenden." "message": "Welche Klasse fügt abgerundete Ecken hinzu?"
}, },
{ {
"type": "contains_class", "type": "contains_class",
"value": "shadow-sm", "value": "shadow-sm",
"message": "Füge <kbd>shadow-sm</kbd> hinzu, um einen kleinen Schlagschatten anzuwenden." "message": "Das Element benötigt einen kleinen Schatten -- welches <kbd>shadow-</kbd>-Utility passt?"
} }
] ]
}, },
@@ -71,17 +71,17 @@
{ {
"type": "contains_class", "type": "contains_class",
"value": "text-blue-600", "value": "text-blue-600",
"message": "Füge <kbd>text-blue-600</kbd> hinzu, um den Text blau zu machen" "message": "Welches <kbd>text-</kbd>-Utility setzt eine blaue Textfarbe? Denke an das <kbd>text-{farbe}-{abstufung}</kbd>-Muster."
}, },
{ {
"type": "contains_class", "type": "contains_class",
"value": "text-2xl", "value": "text-2xl",
"message": "Füge <kbd>text-2xl</kbd> hinzu, um die Schriftgröße auf 1.5rem zu erhöhen" "message": "Welches <kbd>text-</kbd>-Utility setzt die Schriftgröße auf 1.5rem?"
}, },
{ {
"type": "contains_class", "type": "contains_class",
"value": "font-bold", "value": "font-bold",
"message": "Füge <kbd>font-bold</kbd> hinzu, um den Text fett zu machen (font-weight: 700)" "message": "Welches <kbd>font-</kbd>-Utility macht den Text fett?"
} }
] ]
}, },
@@ -99,17 +99,17 @@
{ {
"type": "contains_class", "type": "contains_class",
"value": "px-6", "value": "px-6",
"message": "Füge <kbd>px-6</kbd> für horizontales Padding hinzu (1.5rem links und rechts)" "message": "Welches <kbd>px-</kbd>-Utility erzeugt 1.5rem horizontales Padding?"
}, },
{ {
"type": "contains_class", "type": "contains_class",
"value": "py-3", "value": "py-3",
"message": "Füge <kbd>py-3</kbd> für vertikales Padding hinzu (0.75rem oben und unten)" "message": "Welches <kbd>py-</kbd>-Utility erzeugt 0.75rem vertikales Padding?"
}, },
{ {
"type": "contains_class", "type": "contains_class",
"value": "mx-auto", "value": "mx-auto",
"message": "Füge <kbd>mx-auto</kbd> hinzu, um den Button horizontal zu zentrieren" "message": "Welches <kbd>mx-</kbd>-Utility zentriert ein Element horizontal?"
} }
] ]
}, },
@@ -127,32 +127,32 @@
{ {
"type": "contains_class", "type": "contains_class",
"value": "w-full", "value": "w-full",
"message": "Füge <kbd>w-full</kbd> für 100% Breite auf Mobil hinzu" "message": "Welches Breiten-Utility macht das Element auf Mobil 100% breit?"
}, },
{ {
"type": "contains_class", "type": "contains_class",
"value": "md:w-1/2", "value": "md:w-1/2",
"message": "Füge <kbd>md:w-1/2</kbd> für 50% Breite auf Tablet und größer hinzu" "message": "Welches responsive Breiten-Utility setzt 50% ab dem <kbd>md:</kbd>-Breakpoint?"
}, },
{ {
"type": "contains_class", "type": "contains_class",
"value": "lg:w-1/3", "value": "lg:w-1/3",
"message": "Füge <kbd>lg:w-1/3</kbd> für 33.33% Breite auf Desktop und größer hinzu" "message": "Welches responsive Breiten-Utility setzt 33.33% ab dem <kbd>lg:</kbd>-Breakpoint?"
}, },
{ {
"type": "contains_class", "type": "contains_class",
"value": "text-lg", "value": "text-lg",
"message": "Füge <kbd>text-lg</kbd> für die Basis-Textgröße hinzu" "message": "Welches <kbd>text-</kbd>-Utility setzt die Basis-Textgröße auf 1.125rem?"
}, },
{ {
"type": "contains_class", "type": "contains_class",
"value": "md:text-xl", "value": "md:text-xl",
"message": "Füge <kbd>md:text-xl</kbd> für größeren Text auf Tablets hinzu" "message": "Welches responsive Text-Utility setzt eine größere Schrift ab dem <kbd>md:</kbd>-Breakpoint?"
}, },
{ {
"type": "contains_class", "type": "contains_class",
"value": "lg:text-2xl", "value": "lg:text-2xl",
"message": "Füge <kbd>lg:text-2xl</kbd> für noch größeren Text auf Desktop hinzu" "message": "Welches responsive Text-Utility setzt die größte Schrift ab dem <kbd>lg:</kbd>-Breakpoint?"
} }
] ]
} }

View File

@@ -22,7 +22,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "display", "expected": "flex" }, "value": { "property": "display", "expected": "flex" },
"message": "Setze <kbd>display: flex</kbd>" "message": "Welcher Display-Wert macht ein Element zu einem flexiblen Box-Container?"
} }
] ]
}, },
@@ -43,7 +43,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "gap", "expected": "1rem" }, "value": { "property": "gap", "expected": "1rem" },
"message": "Setze <kbd>gap: 1rem</kbd>" "message": "Welche Eigenschaft erzeugt Abstände zwischen Flex-Items ohne Margins?"
} }
] ]
}, },
@@ -64,7 +64,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "justify-content", "expected": "space-between" }, "value": { "property": "justify-content", "expected": "space-between" },
"message": "Setze <kbd>justify-content: space-between</kbd>" "message": "Welcher <kbd>justify-content</kbd>-Wert schiebt das erste und letzte Element an gegenüberliegende Ränder?"
} }
] ]
}, },
@@ -85,7 +85,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "align-items", "expected": "center" }, "value": { "property": "align-items", "expected": "center" },
"message": "Setze <kbd>align-items: center</kbd>" "message": "Welche Eigenschaft richtet Flex-Items entlang der Querachse aus?"
} }
] ]
}, },
@@ -106,7 +106,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "flex-wrap", "expected": "wrap" }, "value": { "property": "flex-wrap", "expected": "wrap" },
"message": "Setze <kbd>flex-wrap: wrap</kbd>" "message": "Welche Eigenschaft erlaubt Flex-Items, auf mehrere Zeilen umzubrechen?"
} }
] ]
}, },
@@ -127,7 +127,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "flex", "expected": "1" }, "value": { "property": "flex", "expected": "1" },
"message": "Setze <kbd>flex: 1</kbd>" "message": "Welche Eigenschaft lässt ein Flex-Item wachsen, um den verbleibenden Platz zu füllen?"
} }
] ]
} }

View File

@@ -22,7 +22,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "coral" }, "value": { "property": "color", "expected": "coral" },
"message": "Añade <kbd>color: coral;</kbd>" "message": "¿Qué propiedad controla el color del texto?"
} }
] ]
}, },
@@ -43,12 +43,12 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "background", "expected": "lavender" }, "value": { "property": "background", "expected": "lavender" },
"message": "ade <kbd>background: lavender;</kbd>" "message": "Revisa la propiedad <kbd>background</kbd>"
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "padding", "expected": "1rem" }, "value": { "property": "padding", "expected": "1rem" },
"message": "Añade <kbd>padding: 1rem;</kbd>" "message": "La tarjeta necesita espacio dentro de sus bordes"
} }
] ]
}, },
@@ -74,7 +74,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "steelblue" }, "value": { "property": "color", "expected": "steelblue" },
"message": "Establece <kbd>color: steelblue</kbd>" "message": "¿Qué propiedad cambia el color del texto?"
} }
] ]
}, },
@@ -100,7 +100,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "coral" }, "value": { "property": "color", "expected": "coral" },
"message": "Establece <kbd>color: coral</kbd>" "message": "¿Qué valor da un color cálido, rojo-anaranjado?"
} }
] ]
}, },
@@ -126,7 +126,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "background", "expected": "tomato" }, "value": { "property": "background", "expected": "tomato" },
"message": "Establece <kbd>background: tomato</kbd>" "message": "El badge necesita un fondo rojo brillante"
} }
] ]
}, },
@@ -152,7 +152,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "background", "expected": "steelblue" }, "value": { "property": "background", "expected": "steelblue" },
"message": "Establece <kbd>background: steelblue</kbd>" "message": "¿Qué propiedad establece el color de relleno del botón?"
} }
] ]
}, },
@@ -178,7 +178,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "text-decoration", "expected": "none" }, "value": { "property": "text-decoration", "expected": "none" },
"message": "Establece <kbd>text-decoration: none</kbd>" "message": "¿Qué propiedad controla el subrayado de los enlaces?"
} }
] ]
}, },
@@ -199,7 +199,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "steelblue" }, "value": { "property": "color", "expected": "steelblue" },
"message": "Establece <kbd>color: steelblue</kbd>" "message": "Revisa la propiedad <kbd>color</kbd>"
} }
] ]
}, },
@@ -225,7 +225,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "white" }, "value": { "property": "color", "expected": "white" },
"message": "Establece <kbd>color: white</kbd>" "message": "Los enlaces necesitan destacar sobre el fondo azul"
} }
] ]
}, },
@@ -251,7 +251,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "font-size", "expected": "0.9rem" }, "value": { "property": "font-size", "expected": "0.9rem" },
"message": "Establece <kbd>font-size: 0.9rem</kbd>" "message": "Revisa la propiedad <kbd>font-size</kbd> — el texto debería ser ligeramente más pequeño"
} }
] ]
} }

View File

@@ -22,7 +22,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "padding", "expected": "1rem" }, "value": { "property": "padding", "expected": "1rem" },
"message": "Establece <kbd>padding: 1rem</kbd>" "message": "¿Qué propiedad añade espacio entre el contenido de un elemento y su borde?"
} }
] ]
}, },
@@ -43,7 +43,7 @@
{ {
"type": "regex", "type": "regex",
"value": "border-left:\\s*4px\\s+solid\\s+steelblue", "value": "border-left:\\s*4px\\s+solid\\s+steelblue",
"message": "Establece <kbd>border-left: 4px solid steelblue</kbd>", "message": "Usa el atajo <kbd>border-left</kbd> con valores de ancho, estilo y color",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -65,7 +65,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "margin-bottom", "expected": "1rem" }, "value": { "property": "margin-bottom", "expected": "1rem" },
"message": "Establece <kbd>margin-bottom: 1rem</kbd>" "message": "¿Qué propiedad crea espacio debajo de un elemento, separándolo de sus vecinos?"
} }
] ]
}, },
@@ -86,7 +86,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "box-sizing", "expected": "border-box" }, "value": { "property": "box-sizing", "expected": "border-box" },
"message": "Establece <kbd>box-sizing: border-box</kbd>" "message": "¿Qué valor de <kbd>box-sizing</kbd> incluye padding y borde en el ancho total del elemento?"
} }
] ]
}, },
@@ -107,7 +107,7 @@
{ {
"type": "regex", "type": "regex",
"value": "padding:\\s*8px\\s+1rem", "value": "padding:\\s*8px\\s+1rem",
"message": "Establece <kbd>padding: 8px 1rem</kbd>", "message": "Usa el atajo <kbd>padding</kbd> con dos valores: vertical y luego horizontal",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -129,7 +129,7 @@
{ {
"type": "regex", "type": "regex",
"value": "margin:\\s*0\\s+auto", "value": "margin:\\s*0\\s+auto",
"message": "Establece <kbd>margin: 0 auto</kbd>", "message": "Usa <kbd>margin</kbd> con una palabra clave que calcula automáticamente espaciado igual a izquierda y derecha",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -151,7 +151,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "border-radius", "expected": "50%" }, "value": { "property": "border-radius", "expected": "50%" },
"message": "Establece <kbd>border-radius: 50%</kbd>" "message": "¿Qué porcentaje de <kbd>border-radius</kbd> crea un círculo perfecto a partir de un elemento cuadrado?"
} }
] ]
}, },
@@ -172,18 +172,18 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "padding", "expected": "1rem" }, "value": { "property": "padding", "expected": "1rem" },
"message": "Establece <kbd>padding: 1rem</kbd>" "message": "El elemento necesita espacio interior"
}, },
{ {
"type": "regex", "type": "regex",
"value": "border-left:\\s*4px\\s+solid\\s+coral", "value": "border-left:\\s*4px\\s+solid\\s+coral",
"message": "Establece <kbd>border-left: 4px solid coral</kbd>", "message": "Añade un acento de borde izquierdo usando el atajo <kbd>border-left</kbd>",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "border-radius", "expected": "4px" }, "value": { "property": "border-radius", "expected": "4px" },
"message": "Establece <kbd>border-radius: 4px</kbd>" "message": "Redondea las esquinas ligeramente con <kbd>border-radius</kbd>"
} }
] ]
} }

View File

@@ -22,7 +22,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "max-width", "expected": "40rem" }, "value": { "property": "max-width", "expected": "40rem" },
"message": "Establece <kbd>max-width: 40rem</kbd>" "message": "¿Qué propiedad limita el ancho de un elemento? Prueba un valor en <kbd>rem</kbd> para una longitud de línea legible."
} }
] ]
}, },
@@ -49,7 +49,7 @@
{ {
"type": "contains", "type": "contains",
"value": "steelblue", "value": "steelblue",
"message": "Establece el valor a <kbd>steelblue</kbd>", "message": "Asigna el valor <kbd>steelblue</kbd> a la variable",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -71,7 +71,7 @@
{ {
"type": "regex", "type": "regex",
"value": "width:\\s*calc\\(\\s*100%\\s*-\\s*200px\\s*\\)", "value": "width:\\s*calc\\(\\s*100%\\s*-\\s*200px\\s*\\)",
"message": "Establece <kbd>width: calc(100% - 200px)</kbd>", "message": "Usa <kbd>calc()</kbd> para restar el ancho fijo de la barra lateral del ancho total del contenedor.",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -93,7 +93,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "min-height", "expected": "100vh" }, "value": { "property": "min-height", "expected": "100vh" },
"message": "Establece <kbd>min-height: 100vh</kbd>" "message": "¿Qué propiedad asegura una altura mínima? Usa una unidad de viewport para cobertura de pantalla completa."
} }
] ]
} }

View File

@@ -28,7 +28,7 @@
{ {
"type": "regex", "type": "regex",
"value": "transition:\\s*background-color\\s*0\\.3s", "value": "transition:\\s*background-color\\s*0\\.3s",
"message": "Establece <kbd>transition: background-color 0.3s</kbd>", "message": "Especifica qué propiedad transicionar y cuánto debe durar.",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -56,7 +56,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "transition-timing-function", "expected": "ease-in-out" }, "value": { "property": "transition-timing-function", "expected": "ease-in-out" },
"message": "Establece timing a <kbd>ease-in-out</kbd>" "message": "¿Qué palabra clave de easing empieza lento, acelera, y luego desacelera de nuevo?"
} }
] ]
}, },
@@ -95,7 +95,7 @@
{ {
"type": "regex", "type": "regex",
"value": "animation:.*bounce.*1s.*infinite", "value": "animation:.*bounce.*1s.*infinite",
"message": "Aplica <kbd>animation: bounce 1s infinite</kbd>", "message": "Usa el atajo <kbd>animation</kbd>: nombre, duración y número de repeticiones.",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -117,27 +117,27 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "animation-name", "expected": "pulse" }, "value": { "property": "animation-name", "expected": "pulse" },
"message": "Establece <kbd>animation-name: pulse</kbd>" "message": "¿Qué propiedad vincula un elemento a una regla <kbd>@keyframes</kbd> nombrada?"
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "animation-duration", "expected": "2s" }, "value": { "property": "animation-duration", "expected": "2s" },
"message": "Establece <kbd>animation-duration: 2s</kbd>" "message": "¿Cuánto debe durar un ciclo completo de la animación?"
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "animation-delay", "expected": "1s" }, "value": { "property": "animation-delay", "expected": "1s" },
"message": "Establece <kbd>animation-delay: 1s</kbd>" "message": "¿Qué propiedad hace que la animación espere antes de comenzar?"
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "animation-iteration-count", "expected": "2" }, "value": { "property": "animation-iteration-count", "expected": "2" },
"message": "Establece <kbd>animation-iteration-count: 2</kbd>" "message": "¿Qué propiedad controla cuántas veces se repite la animación?"
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "animation-fill-mode", "expected": "forwards" }, "value": { "property": "animation-fill-mode", "expected": "forwards" },
"message": "Establece <kbd>animation-fill-mode: forwards</kbd>" "message": "¿Qué propiedad mantiene el elemento con los estilos de su último keyframe después de que termina la animación?"
} }
] ]
} }

View File

@@ -22,7 +22,7 @@
{ {
"type": "regex", "type": "regex",
"value": "@media\\s*\\(max-width:\\s*600px\\)", "value": "@media\\s*\\(max-width:\\s*600px\\)",
"message": "Usa <kbd>@media (max-width: 600px)</kbd>", "message": "Empieza con una regla <kbd>@media</kbd> — ¿qué condición apunta a pantallas de 600px de ancho o menos?",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
}, },
{ {
@@ -34,7 +34,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "background", "expected": "lightcoral" }, "value": { "property": "background", "expected": "lightcoral" },
"message": "Establece <kbd>background: lightcoral</kbd>", "message": "¿Qué propiedad cambia el color de fondo del elemento?",
"options": { "exact": false } "options": { "exact": false }
} }
] ]
@@ -56,7 +56,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "font-size", "expected": "5vw" }, "value": { "property": "font-size", "expected": "5vw" },
"message": "Establece <kbd>font-size: 5vw</kbd>" "message": "¿Qué unidad CSS escala en relación al ancho del viewport?"
} }
] ]
}, },
@@ -77,18 +77,18 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "display", "expected": "grid" }, "value": { "property": "display", "expected": "grid" },
"message": "Establece <kbd>display: grid</kbd>" "message": "¿Qué modo de display permite definir filas y columnas?"
}, },
{ {
"type": "regex", "type": "regex",
"value": "repeat\\(auto-fit,\\s*minmax\\(200px,\\s*1fr\\)\\)", "value": "repeat\\(auto-fit,\\s*minmax\\(200px,\\s*1fr\\)\\)",
"message": "Usa <kbd>repeat(auto-fit, minmax(200px, 1fr))</kbd>", "message": "Prueba <kbd>repeat()</kbd> con <kbd>auto-fit</kbd> y <kbd>minmax()</kbd> — ¿qué tamaños mínimo y máximo crean columnas flexibles?",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "gap", "expected": "1rem" }, "value": { "property": "gap", "expected": "1rem" },
"message": "Establece <kbd>gap: 1rem</kbd>" "message": "¿Qué propiedad añade espacio entre los elementos del grid?"
} }
] ]
}, },
@@ -109,7 +109,7 @@
{ {
"type": "regex", "type": "regex",
"value": "@media\\s*\\(min-width:\\s*768px\\)", "value": "@media\\s*\\(min-width:\\s*768px\\)",
"message": "Usa <kbd>@media (min-width: 768px)</kbd>", "message": "¿Qué condición <kbd>@media</kbd> aplica estilos cuando el viewport tiene al menos 768px de ancho?",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
}, },
{ {
@@ -121,7 +121,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "width", "expected": "250px" }, "value": { "property": "width", "expected": "250px" },
"message": "Establece <kbd>width: 250px</kbd>", "message": "¿Qué propiedad controla el ancho de la barra lateral en pantallas más grandes?",
"options": { "exact": false } "options": { "exact": false }
} }
] ]

View File

@@ -22,7 +22,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "display", "expected": "flex" }, "value": { "property": "display", "expected": "flex" },
"message": "Establece <kbd>display: flex</kbd>" "message": "¿Qué valor de display convierte un elemento en un contenedor de caja flexible?"
} }
] ]
}, },
@@ -43,7 +43,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "gap", "expected": "1rem" }, "value": { "property": "gap", "expected": "1rem" },
"message": "Establece <kbd>gap: 1rem</kbd>" "message": "¿Qué propiedad crea espaciado entre elementos flex sin usar márgenes?"
} }
] ]
}, },
@@ -64,7 +64,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "justify-content", "expected": "space-between" }, "value": { "property": "justify-content", "expected": "space-between" },
"message": "Establece <kbd>justify-content: space-between</kbd>" "message": "¿Qué valor de <kbd>justify-content</kbd> empuja el primer y último elemento a los extremos opuestos?"
} }
] ]
}, },
@@ -85,7 +85,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "align-items", "expected": "center" }, "value": { "property": "align-items", "expected": "center" },
"message": "Establece <kbd>align-items: center</kbd>" "message": "¿Qué propiedad alinea los elementos flex a lo largo del eje transversal?"
} }
] ]
}, },
@@ -106,7 +106,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "flex-wrap", "expected": "wrap" }, "value": { "property": "flex-wrap", "expected": "wrap" },
"message": "Establece <kbd>flex-wrap: wrap</kbd>" "message": "¿Qué propiedad permite que los elementos flex fluyan a múltiples líneas?"
} }
] ]
}, },
@@ -127,7 +127,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "flex", "expected": "1" }, "value": { "property": "flex", "expected": "1" },
"message": "Establece <kbd>flex: 1</kbd>" "message": "¿Qué propiedad hace que un elemento flex crezca para llenar el espacio restante?"
} }
] ]
} }

View File

@@ -22,7 +22,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "display", "expected": "flex" }, "value": { "property": "display", "expected": "flex" },
"message": "Set <kbd>display: flex</kbd>" "message": "Which display value turns an element into a flexible box container?"
} }
] ]
}, },
@@ -43,7 +43,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "gap", "expected": "1rem" }, "value": { "property": "gap", "expected": "1rem" },
"message": "Set <kbd>gap: 1rem</kbd>" "message": "Which property creates spacing between flex items without using margins?"
} }
] ]
}, },
@@ -64,7 +64,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "justify-content", "expected": "space-between" }, "value": { "property": "justify-content", "expected": "space-between" },
"message": "Set <kbd>justify-content: space-between</kbd>" "message": "Which <kbd>justify-content</kbd> value pushes the first and last items to opposite edges?"
} }
] ]
}, },
@@ -85,7 +85,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "align-items", "expected": "center" }, "value": { "property": "align-items", "expected": "center" },
"message": "Set <kbd>align-items: center</kbd>" "message": "Which property aligns flex items along the cross axis?"
} }
] ]
}, },
@@ -106,7 +106,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "flex-wrap", "expected": "wrap" }, "value": { "property": "flex-wrap", "expected": "wrap" },
"message": "Set <kbd>flex-wrap: wrap</kbd>" "message": "Which property allows flex items to flow onto multiple lines?"
} }
] ]
}, },
@@ -127,7 +127,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "flex", "expected": "1" }, "value": { "property": "flex", "expected": "1" },
"message": "Set <kbd>flex: 1</kbd>" "message": "Which property makes a flex item grow to fill the remaining space?"
} }
] ]
} }

View File

@@ -22,7 +22,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "display", "expected": "grid" }, "value": { "property": "display", "expected": "grid" },
"message": "Set <kbd>display: grid</kbd>" "message": "Which <kbd>display</kbd> value activates the CSS Grid layout system?"
} }
] ]
}, },
@@ -43,7 +43,7 @@
{ {
"type": "regex", "type": "regex",
"value": "grid-template-columns:\\s*repeat\\(\\s*3\\s*,\\s*1fr\\s*\\)", "value": "grid-template-columns:\\s*repeat\\(\\s*3\\s*,\\s*1fr\\s*\\)",
"message": "Set <kbd>grid-template-columns: repeat(3, 1fr)</kbd>", "message": "Which CSS property defines column sizes in a grid? Use <kbd>repeat()</kbd> with the <kbd>fr</kbd> unit for equal columns.",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -65,7 +65,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "gap", "expected": "1rem" }, "value": { "property": "gap", "expected": "1rem" },
"message": "Set <kbd>gap: 1rem</kbd>" "message": "Which CSS property adds spacing between grid cells without affecting the outer edges?"
} }
] ]
}, },
@@ -86,7 +86,7 @@
{ {
"type": "regex", "type": "regex",
"value": "grid-column:\\s*span\\s+2", "value": "grid-column:\\s*span\\s+2",
"message": "Set <kbd>grid-column: span 2</kbd>", "message": "Which CSS property makes a grid item stretch across multiple columns? Use the <kbd>span</kbd> keyword.",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -108,7 +108,7 @@
{ {
"type": "regex", "type": "regex",
"value": "grid-template-columns:\\s*repeat\\(\\s*auto-fit\\s*,\\s*minmax\\(\\s*150px\\s*,\\s*1fr\\s*\\)\\s*\\)", "value": "grid-template-columns:\\s*repeat\\(\\s*auto-fit\\s*,\\s*minmax\\(\\s*150px\\s*,\\s*1fr\\s*\\)\\s*\\)",
"message": "Set <kbd>grid-template-columns: repeat(auto-fit, minmax(150px, 1fr))</kbd>", "message": "Which CSS property creates responsive columns? Combine <kbd>auto-fit</kbd> with <kbd>minmax()</kbd> for flexible sizing.",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]

View File

@@ -22,7 +22,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "coral" }, "value": { "property": "color", "expected": "coral" },
"message": "Dodaj <kbd>color: coral;</kbd>" "message": "Która właściwość kontroluje kolor tekstu?"
} }
] ]
}, },
@@ -43,12 +43,12 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "background", "expected": "lavender" }, "value": { "property": "background", "expected": "lavender" },
"message": "Dodaj <kbd>background: lavender;</kbd>" "message": "Sprawdź właściwość <kbd>background</kbd> — jaki kolor potrzebuje karta?"
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "padding", "expected": "1rem" }, "value": { "property": "padding", "expected": "1rem" },
"message": "Dodaj <kbd>padding: 1rem;</kbd>" "message": "Element potrzebuje wewnętrznej przestrzeni — sprawdź właściwość <kbd>padding</kbd>"
} }
] ]
}, },
@@ -74,7 +74,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "steelblue" }, "value": { "property": "color", "expected": "steelblue" },
"message": "Ustaw <kbd>color: steelblue</kbd>" "message": "Która właściwość kontroluje kolor tekstu?"
} }
] ]
}, },
@@ -100,7 +100,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "coral" }, "value": { "property": "color", "expected": "coral" },
"message": "Ustaw <kbd>color: coral</kbd>" "message": "Sprawdź właściwość <kbd>color</kbd> — jaki kolor potrzebują linki?"
} }
] ]
}, },
@@ -126,7 +126,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "background", "expected": "tomato" }, "value": { "property": "background", "expected": "tomato" },
"message": "Ustaw <kbd>background: tomato</kbd>" "message": "Sprawdź właściwość <kbd>background</kbd> — jaki kolor potrzebuje badge?"
} }
] ]
}, },
@@ -152,7 +152,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "background", "expected": "steelblue" }, "value": { "property": "background", "expected": "steelblue" },
"message": "Ustaw <kbd>background: steelblue</kbd>" "message": "Sprawdź właściwość <kbd>background</kbd> — jaki kolor potrzebuje przycisk?"
} }
] ]
}, },
@@ -178,7 +178,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "text-decoration", "expected": "none" }, "value": { "property": "text-decoration", "expected": "none" },
"message": "Ustaw <kbd>text-decoration: none</kbd>" "message": "Która właściwość kontroluje podkreślenie linków?"
} }
] ]
}, },
@@ -199,7 +199,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "steelblue" }, "value": { "property": "color", "expected": "steelblue" },
"message": "Ustaw <kbd>color: steelblue</kbd>" "message": "Która właściwość kontroluje kolor tekstu nagłówków?"
} }
] ]
}, },
@@ -225,7 +225,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "white" }, "value": { "property": "color", "expected": "white" },
"message": "Ustaw <kbd>color: white</kbd>" "message": "Sprawdź właściwość <kbd>color</kbd> — jaki kolor potrzebują linki nawigacji?"
} }
] ]
}, },
@@ -251,7 +251,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "font-size", "expected": "0.9rem" }, "value": { "property": "font-size", "expected": "0.9rem" },
"message": "Ustaw <kbd>font-size: 0.9rem</kbd>" "message": "Która właściwość kontroluje rozmiar tekstu?"
} }
] ]
} }

View File

@@ -22,7 +22,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "padding", "expected": "1rem" }, "value": { "property": "padding", "expected": "1rem" },
"message": "Ustaw <kbd>padding: 1rem</kbd>" "message": "Element potrzebuje wewnętrznej przestrzeni — sprawdź właściwość <kbd>padding</kbd>"
} }
] ]
}, },
@@ -43,7 +43,7 @@
{ {
"type": "regex", "type": "regex",
"value": "border-left:\\s*4px\\s+solid\\s+steelblue", "value": "border-left:\\s*4px\\s+solid\\s+steelblue",
"message": "Ustaw <kbd>border-left: 4px solid steelblue</kbd>", "message": "Sprawdź właściwość <kbd>border-left</kbd> — jakiej szerokości, stylu i koloru potrzebujesz?",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -65,7 +65,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "margin-bottom", "expected": "1rem" }, "value": { "property": "margin-bottom", "expected": "1rem" },
"message": "Ustaw <kbd>margin-bottom: 1rem</kbd>" "message": "Która właściwość kontroluje przestrzeń pod elementem?"
} }
] ]
}, },
@@ -86,7 +86,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "box-sizing", "expected": "border-box" }, "value": { "property": "box-sizing", "expected": "border-box" },
"message": "Ustaw <kbd>box-sizing: border-box</kbd>" "message": "Która wartość <kbd>box-sizing</kbd> włącza padding i ramkę do szerokości?"
} }
] ]
}, },
@@ -107,7 +107,7 @@
{ {
"type": "regex", "type": "regex",
"value": "padding:\\s*8px\\s+1rem", "value": "padding:\\s*8px\\s+1rem",
"message": "Ustaw <kbd>padding: 8px 1rem</kbd>", "message": "Sprawdź skrót <kbd>padding</kbd> — dwie wartości oznaczają pion i poziom",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -129,7 +129,7 @@
{ {
"type": "regex", "type": "regex",
"value": "margin:\\s*0\\s+auto", "value": "margin:\\s*0\\s+auto",
"message": "Ustaw <kbd>margin: 0 auto</kbd>", "message": "Sprawdź skrót <kbd>margin</kbd> — jak automatycznie wycentrować element poziomo?",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -151,7 +151,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "border-radius", "expected": "50%" }, "value": { "property": "border-radius", "expected": "50%" },
"message": "Ustaw <kbd>border-radius: 50%</kbd>" "message": "Która wartość <kbd>border-radius</kbd> tworzy pełne koło?"
} }
] ]
}, },
@@ -172,18 +172,18 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "padding", "expected": "1rem" }, "value": { "property": "padding", "expected": "1rem" },
"message": "Ustaw <kbd>padding: 1rem</kbd>" "message": "Element potrzebuje wewnętrznej przestrzeni — sprawdź właściwość <kbd>padding</kbd>"
}, },
{ {
"type": "regex", "type": "regex",
"value": "border-left:\\s*4px\\s+solid\\s+coral", "value": "border-left:\\s*4px\\s+solid\\s+coral",
"message": "Ustaw <kbd>border-left: 4px solid coral</kbd>", "message": "Sprawdź właściwość <kbd>border-left</kbd> — jaki styl akcentu potrzebuje powiadomienie?",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "border-radius", "expected": "4px" }, "value": { "property": "border-radius", "expected": "4px" },
"message": "Ustaw <kbd>border-radius: 4px</kbd>" "message": "Element potrzebuje zaokrąglonych rogów — sprawdź właściwość <kbd>border-radius</kbd>"
} }
] ]
} }

View File

@@ -22,7 +22,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "max-width", "expected": "40rem" }, "value": { "property": "max-width", "expected": "40rem" },
"message": "Ustaw <kbd>max-width: 40rem</kbd>" "message": "Która właściwość ogranicza maksymalną szerokość elementu?"
} }
] ]
}, },
@@ -49,7 +49,7 @@
{ {
"type": "contains", "type": "contains",
"value": "steelblue", "value": "steelblue",
"message": "Ustaw wartość na <kbd>steelblue</kbd>", "message": "Jaki kolor powinna mieć zmienna brand?",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -71,7 +71,7 @@
{ {
"type": "regex", "type": "regex",
"value": "width:\\s*calc\\(\\s*100%\\s*-\\s*200px\\s*\\)", "value": "width:\\s*calc\\(\\s*100%\\s*-\\s*200px\\s*\\)",
"message": "Ustaw <kbd>width: calc(100% - 200px)</kbd>", "message": "Sprawdź funkcję <kbd>calc()</kbd> — jak obliczyć szerokość minus sidebar?",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -93,7 +93,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "min-height", "expected": "100vh" }, "value": { "property": "min-height", "expected": "100vh" },
"message": "Ustaw <kbd>min-height: 100vh</kbd>" "message": "Która właściwość zapewnia minimalną wysokość na cały viewport?"
} }
] ]
} }

View File

@@ -28,7 +28,7 @@
{ {
"type": "regex", "type": "regex",
"value": "transition:\\s*background-color\\s*0\\.3s", "value": "transition:\\s*background-color\\s*0\\.3s",
"message": "Ustaw <kbd>transition: background-color 0.3s</kbd>", "message": "Sprawdź właściwość <kbd>transition</kbd> — jaką właściwość i czas trwania podać?",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -56,7 +56,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "transition-timing-function", "expected": "ease-in-out" }, "value": { "property": "transition-timing-function", "expected": "ease-in-out" },
"message": "Ustaw timing na <kbd>ease-in-out</kbd>" "message": "Która wartość tworzy płynne przyspieszenie i spowolnienie?"
} }
] ]
}, },
@@ -83,7 +83,7 @@
{ {
"type": "regex", "type": "regex",
"value": "50%.*transform: translateY\\(-20px\\)", "value": "50%.*transform: translateY\\(-20px\\)",
"message": "Przy <kbd>50%</kbd>, użyj <kbd>transform: translateY(-20px)</kbd>", "message": "W połowie animacji piłka powinna podskoczyć w górę — sprawdź <kbd>transform</kbd>",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
}, },
{ {
@@ -95,7 +95,7 @@
{ {
"type": "regex", "type": "regex",
"value": "animation:.*bounce.*1s.*infinite", "value": "animation:.*bounce.*1s.*infinite",
"message": "Zastosuj <kbd>animation: bounce 1s infinite</kbd>", "message": "Sprawdź skrót <kbd>animation</kbd> — podaj nazwę, czas trwania i powtarzanie",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -117,27 +117,27 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "animation-name", "expected": "pulse" }, "value": { "property": "animation-name", "expected": "pulse" },
"message": "Ustaw <kbd>animation-name: pulse</kbd>" "message": "Która właściwość wskazuje nazwę animacji do zastosowania?"
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "animation-duration", "expected": "2s" }, "value": { "property": "animation-duration", "expected": "2s" },
"message": "Ustaw <kbd>animation-duration: 2s</kbd>" "message": "Sprawdź właściwość <kbd>animation-duration</kbd> — jak długo trwa jeden cykl?"
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "animation-delay", "expected": "1s" }, "value": { "property": "animation-delay", "expected": "1s" },
"message": "Ustaw <kbd>animation-delay: 1s</kbd>" "message": "Sprawdź właściwość <kbd>animation-delay</kbd> — ile czeka przed startem?"
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "animation-iteration-count", "expected": "2" }, "value": { "property": "animation-iteration-count", "expected": "2" },
"message": "Ustaw <kbd>animation-iteration-count: 2</kbd>" "message": "Sprawdź właściwość <kbd>animation-iteration-count</kbd> — ile razy ma się powtórzyć?"
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "animation-fill-mode", "expected": "forwards" }, "value": { "property": "animation-fill-mode", "expected": "forwards" },
"message": "Ustaw <kbd>animation-fill-mode: forwards</kbd>" "message": "Która wartość <kbd>animation-fill-mode</kbd> zachowuje końcowy stan animacji?"
} }
] ]
} }

View File

@@ -34,7 +34,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "background", "expected": "lightcoral" }, "value": { "property": "background", "expected": "lightcoral" },
"message": "Ustaw <kbd>background: lightcoral</kbd>", "message": "Sprawdź właściwość <kbd>background</kbd> — jaki kolor potrzebuje panel na małych ekranach?",
"options": { "exact": false } "options": { "exact": false }
} }
] ]
@@ -53,7 +53,11 @@
"solution": " font-size: 5vw;", "solution": " font-size: 5vw;",
"previewContainer": "preview-area", "previewContainer": "preview-area",
"validations": [ "validations": [
{ "type": "property_value", "value": { "property": "font-size", "expected": "5vw" }, "message": "Ustaw <kbd>font-size: 5vw</kbd>" } {
"type": "property_value",
"value": { "property": "font-size", "expected": "5vw" },
"message": "Sprawdź właściwość <kbd>font-size</kbd> — która jednostka skaluje się z viewport?"
}
] ]
}, },
{ {
@@ -73,7 +77,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "display", "expected": "grid" }, "value": { "property": "display", "expected": "grid" },
"message": "Ustaw <kbd>display: grid</kbd>" "message": "Która wartość <kbd>display</kbd> włącza układ siatkowy?"
}, },
{ {
"type": "regex", "type": "regex",
@@ -84,7 +88,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "gap", "expected": "1rem" }, "value": { "property": "gap", "expected": "1rem" },
"message": "Ustaw <kbd>gap: 1rem</kbd>" "message": "Sprawdź właściwość <kbd>gap</kbd> — jaki odstęp potrzebują elementy siatki?"
} }
] ]
}, },
@@ -117,7 +121,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "width", "expected": "250px" }, "value": { "property": "width", "expected": "250px" },
"message": "Ustaw <kbd>width: 250px</kbd>", "message": "Sprawdź właściwość <kbd>width</kbd> — jaką stałą szerokość potrzebuje sidebar?",
"options": { "exact": false } "options": { "exact": false }
} }
] ]

View File

@@ -22,7 +22,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "display", "expected": "flex" }, "value": { "property": "display", "expected": "flex" },
"message": "Ustaw <kbd>display: flex</kbd>" "message": "Która właściwość <kbd>display</kbd> tworzy kontener flex?"
} }
] ]
}, },
@@ -43,7 +43,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "gap", "expected": "1rem" }, "value": { "property": "gap", "expected": "1rem" },
"message": "Ustaw <kbd>gap: 1rem</kbd>" "message": "Sprawdź właściwość <kbd>gap</kbd> — jaki odstęp potrzebują elementy?"
} }
] ]
}, },
@@ -64,7 +64,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "justify-content", "expected": "space-between" }, "value": { "property": "justify-content", "expected": "space-between" },
"message": "Ustaw <kbd>justify-content: space-between</kbd>" "message": "Która wartość <kbd>justify-content</kbd> rozdziela elementy na końce?"
} }
] ]
}, },
@@ -85,7 +85,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "align-items", "expected": "center" }, "value": { "property": "align-items", "expected": "center" },
"message": "Ustaw <kbd>align-items: center</kbd>" "message": "Która właściwość kontroluje wyrównanie na osi poprzecznej?"
} }
] ]
}, },
@@ -106,7 +106,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "flex-wrap", "expected": "wrap" }, "value": { "property": "flex-wrap", "expected": "wrap" },
"message": "Ustaw <kbd>flex-wrap: wrap</kbd>" "message": "Sprawdź właściwość <kbd>flex-wrap</kbd> — jak pozwolić elementom przenosić się na nowe linie?"
} }
] ]
}, },
@@ -127,7 +127,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "flex", "expected": "1" }, "value": { "property": "flex", "expected": "1" },
"message": "Ustaw <kbd>flex: 1</kbd>" "message": "Sprawdź właściwość <kbd>flex</kbd> — jak sprawić, by element wypełnił dostępną przestrzeń?"
} }
] ]
} }

View File

@@ -22,7 +22,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "coral" }, "value": { "property": "color", "expected": "coral" },
"message": "Додайте <kbd>color: coral;</kbd>" "message": "Яка властивість керує кольором тексту?"
} }
] ]
}, },
@@ -43,12 +43,12 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "background", "expected": "lavender" }, "value": { "property": "background", "expected": "lavender" },
"message": "Додайте <kbd>background: lavender;</kbd>" "message": "Перевірте властивість <kbd>background</kbd>"
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "padding", "expected": "1rem" }, "value": { "property": "padding", "expected": "1rem" },
"message": "Додайте <kbd>padding: 1rem;</kbd>" "message": "Картка потребує простору всередині її меж"
} }
] ]
}, },
@@ -74,7 +74,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "steelblue" }, "value": { "property": "color", "expected": "steelblue" },
"message": "Встановіть <kbd>color: steelblue</kbd>" "message": "Яка властивість змінює колір тексту?"
} }
] ]
}, },
@@ -100,7 +100,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "coral" }, "value": { "property": "color", "expected": "coral" },
"message": "Встановіть <kbd>color: coral</kbd>" "message": "Яке значення дає теплий червонувато-оранжевий колір?"
} }
] ]
}, },
@@ -126,7 +126,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "background", "expected": "tomato" }, "value": { "property": "background", "expected": "tomato" },
"message": "Встановіть <kbd>background: tomato</kbd>" "message": "Значку потрібен яскравий червоний фон"
} }
] ]
}, },
@@ -152,7 +152,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "background", "expected": "steelblue" }, "value": { "property": "background", "expected": "steelblue" },
"message": "Встановіть <kbd>background: steelblue</kbd>" "message": "Яка властивість встановлює колір заливки кнопки?"
} }
] ]
}, },
@@ -178,7 +178,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "text-decoration", "expected": "none" }, "value": { "property": "text-decoration", "expected": "none" },
"message": "Встановіть <kbd>text-decoration: none</kbd>" "message": "Яка властивість керує підкресленням посилань?"
} }
] ]
}, },
@@ -199,7 +199,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "steelblue" }, "value": { "property": "color", "expected": "steelblue" },
"message": "Встановіть <kbd>color: steelblue</kbd>" "message": "Перевірте властивість <kbd>color</kbd>"
} }
] ]
}, },
@@ -225,7 +225,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "color", "expected": "white" }, "value": { "property": "color", "expected": "white" },
"message": "Встановіть <kbd>color: white</kbd>" "message": "Посилання мають виділятися на синьому фоні"
} }
] ]
}, },
@@ -251,7 +251,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "font-size", "expected": "0.9rem" }, "value": { "property": "font-size", "expected": "0.9rem" },
"message": "Встановіть <kbd>font-size: 0.9rem</kbd>" "message": "Перевірте властивість <kbd>font-size</kbd> — текст має бути трохи меншим"
} }
] ]
} }

View File

@@ -22,7 +22,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "padding", "expected": "1rem" }, "value": { "property": "padding", "expected": "1rem" },
"message": "Встановіть <kbd>padding: 1rem</kbd>" "message": "Яка властивість додає простір між вмістом елемента та його межею?"
} }
] ]
}, },
@@ -43,7 +43,7 @@
{ {
"type": "regex", "type": "regex",
"value": "border-left:\\s*4px\\s+solid\\s+steelblue", "value": "border-left:\\s*4px\\s+solid\\s+steelblue",
"message": "Встановіть <kbd>border-left: 4px solid steelblue</kbd>", "message": "Використайте скорочення <kbd>border-left</kbd> зі значеннями ширини, стилю та кольору",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -65,7 +65,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "margin-bottom", "expected": "1rem" }, "value": { "property": "margin-bottom", "expected": "1rem" },
"message": "Встановіть <kbd>margin-bottom: 1rem</kbd>" "message": "Яка властивість створює простір знизу елемента, відштовхуючи сусідів?"
} }
] ]
}, },
@@ -86,7 +86,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "box-sizing", "expected": "border-box" }, "value": { "property": "box-sizing", "expected": "border-box" },
"message": "Встановіть <kbd>box-sizing: border-box</kbd>" "message": "Яке значення <kbd>box-sizing</kbd> включає padding та межу в загальну ширину елемента?"
} }
] ]
}, },
@@ -107,7 +107,7 @@
{ {
"type": "regex", "type": "regex",
"value": "padding:\\s*8px\\s+1rem", "value": "padding:\\s*8px\\s+1rem",
"message": "Встановіть <kbd>padding: 8px 1rem</kbd>", "message": "Використайте скорочення <kbd>padding</kbd> з двома значеннями: вертикальне та горизонтальне",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -129,7 +129,7 @@
{ {
"type": "regex", "type": "regex",
"value": "margin:\\s*0\\s+auto", "value": "margin:\\s*0\\s+auto",
"message": "Встановіть <kbd>margin: 0 auto</kbd>", "message": "Використайте <kbd>margin</kbd> з ключовим словом, яке автоматично обчислює рівні ліві та праві відступи",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -151,7 +151,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "border-radius", "expected": "50%" }, "value": { "property": "border-radius", "expected": "50%" },
"message": "Встановіть <kbd>border-radius: 50%</kbd>" "message": "Який відсоток <kbd>border-radius</kbd> створює ідеальне коло з квадратного елемента?"
} }
] ]
}, },
@@ -172,18 +172,18 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "padding", "expected": "1rem" }, "value": { "property": "padding", "expected": "1rem" },
"message": "Встановіть <kbd>padding: 1rem</kbd>" "message": "Додайте внутрішній відступ до картки сповіщення"
}, },
{ {
"type": "regex", "type": "regex",
"value": "border-left:\\s*4px\\s+solid\\s+coral", "value": "border-left:\\s*4px\\s+solid\\s+coral",
"message": "Встановіть <kbd>border-left: 4px solid coral</kbd>", "message": "Додайте лівий акцент за допомогою скорочення <kbd>border-left</kbd>",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "border-radius", "expected": "4px" }, "value": { "property": "border-radius", "expected": "4px" },
"message": "Встановіть <kbd>border-radius: 4px</kbd>" "message": "Злегка заокругліть кути за допомогою <kbd>border-radius</kbd>"
} }
] ]
} }

View File

@@ -22,7 +22,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "max-width", "expected": "40rem" }, "value": { "property": "max-width", "expected": "40rem" },
"message": "Встановіть <kbd>max-width: 40rem</kbd>" "message": "Яка властивість обмежує ширину елемента? Спробуйте значення в <kbd>rem</kbd> для комфортної довжини рядка."
} }
] ]
}, },
@@ -49,7 +49,7 @@
{ {
"type": "contains", "type": "contains",
"value": "steelblue", "value": "steelblue",
"message": "Встановіть значення <kbd>steelblue</kbd>", "message": "Встановіть значення на <kbd>steelblue</kbd>",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -71,7 +71,7 @@
{ {
"type": "regex", "type": "regex",
"value": "width:\\s*calc\\(\\s*100%\\s*-\\s*200px\\s*\\)", "value": "width:\\s*calc\\(\\s*100%\\s*-\\s*200px\\s*\\)",
"message": "Встановіть <kbd>width: calc(100% - 200px)</kbd>", "message": "Використайте <kbd>calc()</kbd>, щоб відняти фіксовану ширину сайдбару від повної ширини контейнера.",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -93,7 +93,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "min-height", "expected": "100vh" }, "value": { "property": "min-height", "expected": "100vh" },
"message": "Встановіть <kbd>min-height: 100vh</kbd>" "message": "Яка властивість забезпечує мінімальну висоту? Використайте одиницю viewport для повноекранного покриття."
} }
] ]
} }

View File

@@ -28,7 +28,7 @@
{ {
"type": "regex", "type": "regex",
"value": "transition:\\s*background-color\\s*0\\.3s", "value": "transition:\\s*background-color\\s*0\\.3s",
"message": "Встановіть <kbd>transition: background-color 0.3s</kbd>", "message": "Вкажіть, яку властивість анімувати та скільки це має тривати.",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -56,7 +56,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "transition-timing-function", "expected": "ease-in-out" }, "value": { "property": "transition-timing-function", "expected": "ease-in-out" },
"message": "Встановіть timing на <kbd>ease-in-out</kbd>" "message": "Яке ключове слово пом'якшення починається повільно, прискорюється, а потім знову сповільнюється?"
} }
] ]
}, },
@@ -95,7 +95,7 @@
{ {
"type": "regex", "type": "regex",
"value": "animation:.*bounce.*1s.*infinite", "value": "animation:.*bounce.*1s.*infinite",
"message": "Застосуйте <kbd>animation: bounce 1s infinite</kbd>", "message": "Використайте скорочення <kbd>animation</kbd>: назва, тривалість та кількість повторень.",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
} }
] ]
@@ -117,27 +117,27 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "animation-name", "expected": "pulse" }, "value": { "property": "animation-name", "expected": "pulse" },
"message": "Встановіть <kbd>animation-name: pulse</kbd>" "message": "Яка властивість пов'язує елемент з іменованим правилом <kbd>@keyframes</kbd>?"
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "animation-duration", "expected": "2s" }, "value": { "property": "animation-duration", "expected": "2s" },
"message": "Встановіть <kbd>animation-duration: 2s</kbd>" "message": "Скільки має тривати один повний цикл анімації?"
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "animation-delay", "expected": "1s" }, "value": { "property": "animation-delay", "expected": "1s" },
"message": "Встановіть <kbd>animation-delay: 1s</kbd>" "message": "Яка властивість змушує анімацію зачекати перед початком?"
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "animation-iteration-count", "expected": "2" }, "value": { "property": "animation-iteration-count", "expected": "2" },
"message": "Встановіть <kbd>animation-iteration-count: 2</kbd>" "message": "Яка властивість контролює кількість повторень анімації?"
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "animation-fill-mode", "expected": "forwards" }, "value": { "property": "animation-fill-mode", "expected": "forwards" },
"message": "Встановіть <kbd>animation-fill-mode: forwards</kbd>" "message": "Яка властивість зберігає стиль елемента в його фінальному стані keyframe після завершення анімації?"
} }
] ]
} }

View File

@@ -22,7 +22,7 @@
{ {
"type": "regex", "type": "regex",
"value": "@media\\s*\\(max-width:\\s*600px\\)", "value": "@media\\s*\\(max-width:\\s*600px\\)",
"message": "Використайте <kbd>@media (max-width: 600px)</kbd>", "message": "Почніть з правила <kbd>@media</kbd> — яка умова націлюється на екрани шириною 600px або менше?",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
}, },
{ {
@@ -34,7 +34,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "background", "expected": "lightcoral" }, "value": { "property": "background", "expected": "lightcoral" },
"message": "Встановіть <kbd>background: lightcoral</kbd>", "message": "Яка властивість змінює колір фону елемента?",
"options": { "exact": false } "options": { "exact": false }
} }
] ]
@@ -56,7 +56,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "font-size", "expected": "5vw" }, "value": { "property": "font-size", "expected": "5vw" },
"message": "Встановіть <kbd>font-size: 5vw</kbd>" "message": "Яка одиниця CSS масштабується відносно ширини viewport?"
} }
] ]
}, },
@@ -77,18 +77,18 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "display", "expected": "grid" }, "value": { "property": "display", "expected": "grid" },
"message": "Встановіть <kbd>display: grid</kbd>" "message": "Який режим display дозволяє визначати рядки та колонки?"
}, },
{ {
"type": "regex", "type": "regex",
"value": "repeat\\(auto-fit,\\s*minmax\\(200px,\\s*1fr\\)\\)", "value": "repeat\\(auto-fit,\\s*minmax\\(200px,\\s*1fr\\)\\)",
"message": "Використайте <kbd>repeat(auto-fit, minmax(200px, 1fr))</kbd>", "message": "Спробуйте <kbd>repeat()</kbd> з <kbd>auto-fit</kbd> та <kbd>minmax()</kbd> — які мінімальний та максимальний розміри створять гнучкі колонки?",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
}, },
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "gap", "expected": "1rem" }, "value": { "property": "gap", "expected": "1rem" },
"message": "Встановіть <kbd>gap: 1rem</kbd>" "message": "Яка властивість додає простір між елементами grid?"
} }
] ]
}, },
@@ -109,7 +109,7 @@
{ {
"type": "regex", "type": "regex",
"value": "@media\\s*\\(min-width:\\s*768px\\)", "value": "@media\\s*\\(min-width:\\s*768px\\)",
"message": "Використайте <kbd>@media (min-width: 768px)</kbd>", "message": "Яка умова <kbd>@media</kbd> застосовує стилі, коли viewport має ширину щонайменше 768px?",
"options": { "caseSensitive": false } "options": { "caseSensitive": false }
}, },
{ {
@@ -121,7 +121,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "width", "expected": "250px" }, "value": { "property": "width", "expected": "250px" },
"message": "Встановіть <kbd>width: 250px</kbd>", "message": "Яка властивість контролює ширину сайдбару на великих екранах?",
"options": { "exact": false } "options": { "exact": false }
} }
] ]

View File

@@ -22,7 +22,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "display", "expected": "flex" }, "value": { "property": "display", "expected": "flex" },
"message": "Встановіть <kbd>display: flex</kbd>" "message": "Яке значення display перетворює елемент на гнучкий контейнер?"
} }
] ]
}, },
@@ -43,7 +43,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "gap", "expected": "1rem" }, "value": { "property": "gap", "expected": "1rem" },
"message": "Встановіть <kbd>gap: 1rem</kbd>" "message": "Яка властивість створює відстань між flex-елементами без використання margin?"
} }
] ]
}, },
@@ -64,7 +64,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "justify-content", "expected": "space-between" }, "value": { "property": "justify-content", "expected": "space-between" },
"message": "Встановіть <kbd>justify-content: space-between</kbd>" "message": "Яке значення <kbd>justify-content</kbd> розміщує перший та останній елементи на протилежних краях?"
} }
] ]
}, },
@@ -85,7 +85,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "align-items", "expected": "center" }, "value": { "property": "align-items", "expected": "center" },
"message": "Встановіть <kbd>align-items: center</kbd>" "message": "Яка властивість вирівнює flex-елементи вздовж поперечної осі?"
} }
] ]
}, },
@@ -106,7 +106,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "flex-wrap", "expected": "wrap" }, "value": { "property": "flex-wrap", "expected": "wrap" },
"message": "Встановіть <kbd>flex-wrap: wrap</kbd>" "message": "Яка властивість дозволяє flex-елементам переходити на кілька рядків?"
} }
] ]
}, },
@@ -127,7 +127,7 @@
{ {
"type": "property_value", "type": "property_value",
"value": { "property": "flex", "expected": "1" }, "value": { "property": "flex", "expected": "1" },
"message": "Встановіть <kbd>flex: 1</kbd>" "message": "Яка властивість змушує flex-елемент зростати, щоб заповнити залишковий простір?"
} }
] ]
} }

58
package-lock.json generated
View File

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

View File

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

View File

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

View File

@@ -0,0 +1,87 @@
# Implementation Plan
## 1. Objective
Rewrite all answer-revealing validation error messages across lesson JSON files to use pedagogical hints (concept questions, property-name nudges, directional guidance) instead of literal CSS solutions. This eliminates the fail-then-copy anti-pattern and promotes genuine learning.
## 2. Approach
**Phase-based, content-first strategy:**
1. Define a message style guide with 3 hint categories:
- **Concept question:** "Which property adds space inside an element?" (for property discovery)
- **Property hint:** "Check the `padding` property" (when the property is known but value is wrong)
- **Directional nudge:** "The items need to wrap to the next line" (for layout concepts)
2. Rewrite English priority modules first (flexbox, box-model, colors, positioning) — these are 100% answer-revealing and form the template for all other rewrites.
3. Rewrite remaining English modules, reusing the same hint patterns established in step 2.
4. Update localized variants with equivalent pedagogical messages in each target language (ar, de, es, pl, uk), translating the English hints while preserving natural phrasing in each language.
5. Run `npm run format.lessons` to ensure consistent formatting, then run tests.
## 3. File Mapping
### Files to modify (message field only, no validation logic changes):
**English priority (create → N/A, modify → 4, delete → N/A):**
- `lessons/flexbox.json` — modify 6 messages
- `lessons/01-box-model.json` — modify 10 messages
- `lessons/03-colors.json` — modify 4 messages
- `lessons/12-positioning.json` — modify 5 messages
**English remaining (modify → 13):**
- `lessons/00-basics.json` — modify 4 messages
- `lessons/00-basic-selectors.json` — modify 15 messages
- `lessons/01-advanced-selectors.json` — modify 8 messages
- `lessons/04-typography.json` — modify 1 message
- `lessons/05-units-variables.json` — modify 3 messages
- `lessons/06-transitions-animations.json` — modify 8 messages
- `lessons/07-layouts.json` — modify 8 messages
- `lessons/08-responsive.json` — modify 8 messages
- `lessons/09-gradients.json` — modify 3 messages
- `lessons/10-tailwind-basics.json` — modify 16 messages
- `lessons/11-filters.json` — modify 4 messages
- `lessons/13-pseudo-elements.json` — modify 4 messages
- `lessons/grid.json` — modify 5 messages
**Localized variants (modify):**
- `lessons/ar/flexbox.json`, `lessons/ar/01-box-model.json`, + other ar/ modules with answer-revealing messages
- `lessons/de/flexbox.json`, `lessons/de/01-box-model.json`, + other de/ modules
- `lessons/es/flexbox.json`, `lessons/es/01-box-model.json`, + other es/ modules
- `lessons/pl/flexbox.json`, `lessons/pl/01-box-model.json`, + other pl/ modules
- `lessons/uk/flexbox.json`, `lessons/uk/01-box-model.json`, + other uk/ modules
**No new files or deleted files.**
## 4. Architecture Decisions
1. **Message-only changes:** Only the `"message"` string within validation objects is modified. The `type`, `value`, and `options` fields remain untouched. This preserves all validation logic.
2. **No code changes to validator.js:** The validator reads the `message` field as a passthrough string for display. No runtime changes needed.
3. **Hint style per validation type:**
- `property_value` validations → concept question or property hint (since the property and value are tested programmatically, the message should teach the concept, not repeat the answer)
- `regex` validations → directional nudge describing the expected pattern conceptually
- `contains` / `contains_class` validations → concept question about what to include
4. **Localization approach:** Each localized message should be a natural translation of the English pedagogical hint, not a word-for-word translation. The hint category (question, nudge, property hint) should match the English version.
5. **Preserve `<kbd>` tags selectively:** `<kbd>` tags may still be used for property names (e.g., "Check the `<kbd>padding</kbd>` property") but never for complete property-value pairs that reveal the answer.
## 5. Risks
| Risk | Likelihood | Mitigation |
|------|-----------|------------|
| Pedagogical hints are too vague, frustrating learners | Medium | Each hint should name the relevant CSS property or concept — just not the exact value. The task description already provides context. |
| Localized translations lose pedagogical intent | Medium | Use consistent hint categories across languages. Review each language for natural phrasing. |
| Existing tests assert on specific message text | Low | Check test files for hardcoded message assertions before changing. Adjust tests if needed. |
| Formatting inconsistency after bulk edits | Low | Run `npm run format.lessons` after all changes. |
## 6. Testing Strategy
1. **Existing test suite:** Run `npm run test` to verify no regressions. The validator tests should pass since validation logic is unchanged.
2. **Grep audit:** After changes, grep all lesson files for remaining "Set <kbd>" patterns to confirm none were missed.
3. **JSON validity:** Ensure all modified JSON files parse correctly (the format.lessons command will catch syntax errors).
4. **Manual spot-check:** Verify a few lessons in the dev server to confirm messages display correctly in the UI.

View File

@@ -0,0 +1,50 @@
# 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).
## Acceptance Criteria
1. Validation error messages in **flexbox**, **box-model**, and **colors** modules must no longer reveal the exact CSS property-value answer
2. Replacement messages should use pedagogical hints: concept questions, property-name hints, or directional guidance — never the literal solution
3. All remaining English lesson modules with answer-revealing messages must also be rewritten
4. Localized variants (ar/, de/, es/, pl/, uk/) of affected modules must be updated with equivalent pedagogical messages in each language
5. Existing validations (type, value, options) must remain unchanged — only the `"message"` field is modified
6. All existing tests must continue to pass
## Scope
### English priority modules (100% answer-revealing):
- `lessons/flexbox.json` — 6 messages
- `lessons/01-box-model.json` — 10 messages
- `lessons/03-colors.json` — 4 messages
- `lessons/12-positioning.json` — 5 messages
### English remaining modules (partial answer-revealing):
- `lessons/00-basics.json` — 4 of 26
- `lessons/00-basic-selectors.json` — 15 of 18
- `lessons/01-advanced-selectors.json` — 8 of 49
- `lessons/04-typography.json` — 1 of 9
- `lessons/05-units-variables.json` — 3 of 5
- `lessons/06-transitions-animations.json` — 8 of 13
- `lessons/07-layouts.json` — 8 of 11
- `lessons/08-responsive.json` — 8 of 10
- `lessons/09-gradients.json` — 3 of 7
- `lessons/10-tailwind-basics.json` — 16 of 17
- `lessons/11-filters.json` — 4 of 7
- `lessons/13-pseudo-elements.json` — 4 of 8
- `lessons/grid.json` — 5 of 9
### Localized variants (each language directory):
- `lessons/ar/` — Arabic
- `lessons/de/` — German
- `lessons/es/` — Spanish
- `lessons/pl/` — Polish
- `lessons/uk/` — Ukrainian

View File

@@ -0,0 +1,39 @@
# Tasks
## Phase 1: Preparation
- [X] Task 1.1: Audit existing tests for hardcoded validation message assertions; note any that need updating
- [X] Task 1.2: Read each priority English module and draft replacement messages using the hint style guide (concept question / property hint / directional nudge)
## Phase 2: English Priority Modules (100% answer-revealing)
- [X] Task 2.1: Rewrite validation messages in `lessons/flexbox.json` (6 messages) [P]
- [X] Task 2.2: Rewrite validation messages in `lessons/01-box-model.json` (10 messages) [P]
- [X] Task 2.3: Rewrite validation messages in `lessons/03-colors.json` (4 messages) [P]
- [X] Task 2.4: Rewrite validation messages in `lessons/12-positioning.json` (5 messages) [P]
## Phase 3: English Remaining Modules
- [X] Task 3.1: Rewrite messages in `lessons/00-basic-selectors.json` (15 messages) [P]
- [X] Task 3.2: Rewrite messages in `lessons/00-basics.json` (4 messages) [P]
- [X] Task 3.3: Rewrite messages in `lessons/01-advanced-selectors.json` (8 messages) [P]
- [X] Task 3.4: Rewrite messages in `lessons/04-typography.json` (1 message) [P]
- [X] Task 3.5: Rewrite messages in `lessons/05-units-variables.json` (3 messages) [P]
- [X] Task 3.6: Rewrite messages in `lessons/06-transitions-animations.json` (8 messages) [P]
- [X] Task 3.7: Rewrite messages in `lessons/07-layouts.json` (8 messages) [P]
- [X] Task 3.8: Rewrite messages in `lessons/08-responsive.json` (8 messages) [P]
- [X] Task 3.9: Rewrite messages in `lessons/09-gradients.json` (3 messages) [P]
- [X] Task 3.10: Rewrite messages in `lessons/10-tailwind-basics.json` (16 messages) [P]
- [X] Task 3.11: Rewrite messages in `lessons/11-filters.json` (4 messages) [P]
- [X] Task 3.12: Rewrite messages in `lessons/13-pseudo-elements.json` (4 messages) [P]
- [X] Task 3.13: Rewrite messages in `lessons/grid.json` (5 messages) [P]
## Phase 4: Localized Variants
- [X] Task 4.1: Update Arabic (ar/) localized modules with pedagogical messages [P]
- [X] Task 4.2: Update German (de/) localized modules with pedagogical messages [P]
- [X] Task 4.3: Update Spanish (es/) localized modules with pedagogical messages [P]
- [X] Task 4.4: Update Polish (pl/) localized modules with pedagogical messages [P]
- [X] Task 4.5: Update Ukrainian (uk/) localized modules with pedagogical messages [P]
## Phase 5: Validation & Polish
- [X] Task 5.1: Run `npm run format.lessons` to ensure JSON formatting consistency
- [X] Task 5.2: Run `npm run test` and fix any test failures related to message text assertions
- [X] Task 5.3: Grep audit — verify no "Set <kbd>" answer-revealing patterns remain in any lesson file
- [X] Task 5.4: Spot-check a few lessons via `npm start` to confirm messages render correctly in the UI

View File

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

View File

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

View File

@@ -30,6 +30,7 @@ import gradientsEN from "../../lessons/09-gradients.json";
import filtersEN from "../../lessons/11-filters.json"; import filtersEN from "../../lessons/11-filters.json";
import positioningEN from "../../lessons/12-positioning.json"; import positioningEN from "../../lessons/12-positioning.json";
import pseudoElementsEN from "../../lessons/13-pseudo-elements.json"; import pseudoElementsEN from "../../lessons/13-pseudo-elements.json";
import markdownBasicsEN from "../../lessons/40-markdown-basics.json";
import playgroundEN from "../../lessons/98-playground.json"; import playgroundEN from "../../lessons/98-playground.json";
import goodbyeEN from "../../lessons/99-goodbye.json"; import goodbyeEN from "../../lessons/99-goodbye.json";
@@ -162,6 +163,8 @@ const moduleStoreEN = [
htmlFieldsetEN, htmlFieldsetEN,
htmlDatalistEN, htmlDatalistEN,
htmlTablesEN, htmlTablesEN,
// Markdown
markdownBasicsEN,
// Outro // Outro
goodbyeEN, goodbyeEN,
playgroundEN playgroundEN
@@ -201,6 +204,8 @@ const moduleStoreDE = [
htmlFieldsetDE, htmlFieldsetDE,
htmlDatalistDE, htmlDatalistDE,
htmlTablesDE, htmlTablesDE,
// Markdown
markdownBasicsEN, // Using EN fallback until translated
// Outro // Outro
goodbyeEN, goodbyeEN,
playgroundEN playgroundEN
@@ -240,6 +245,8 @@ const moduleStorePL = [
htmlFieldsetPL, htmlFieldsetPL,
htmlDatalistPL, htmlDatalistPL,
htmlTablesPL, htmlTablesPL,
// Markdown
markdownBasicsEN, // Using EN fallback until translated
// Outro // Outro
goodbyeEN, goodbyeEN,
playgroundEN playgroundEN
@@ -279,6 +286,8 @@ const moduleStoreES = [
htmlFieldsetES, htmlFieldsetES,
htmlDatalistES, htmlDatalistES,
htmlTablesES, htmlTablesES,
// Markdown
markdownBasicsEN, // Using EN fallback until translated
// Outro // Outro
goodbyeEN, goodbyeEN,
playgroundEN playgroundEN
@@ -318,6 +327,8 @@ const moduleStoreAR = [
htmlFieldsetAR, htmlFieldsetAR,
htmlDatalistAR, htmlDatalistAR,
htmlTablesAR, htmlTablesAR,
// Markdown
markdownBasicsEN, // Using EN fallback until translated
// Outro // Outro
goodbyeEN, goodbyeEN,
playgroundEN playgroundEN
@@ -357,6 +368,8 @@ const moduleStoreUK = [
htmlFieldsetUK, htmlFieldsetUK,
htmlDatalistUK, htmlDatalistUK,
htmlTablesUK, htmlTablesUK,
// Markdown
markdownBasicsEN, // Using EN fallback until translated
// Outro // Outro
goodbyeEN, goodbyeEN,
playgroundEN playgroundEN

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,19 @@
/** /**
* CodeEditor - CodeMirror 6 wrapper with Emmet support * CodeEditor - CodeMirror 6 wrapper with Emmet support
*/ */
import { EditorState, Prec } from "@codemirror/state"; import { EditorState, EditorSelection, Prec, StateField, Compartment } from "@codemirror/state";
import { EditorView, keymap, placeholder } from "@codemirror/view"; import { EditorView, keymap, placeholder, Decoration } from "@codemirror/view";
import { defaultKeymap, historyKeymap, indentMore, indentLess, undo, redo } from "@codemirror/commands"; import { defaultKeymap, historyKeymap, indentMore, indentLess, undo, redo } from "@codemirror/commands";
import { history } from "@codemirror/commands"; import { history } from "@codemirror/commands";
import { html } from "@codemirror/lang-html"; import { html } from "@codemirror/lang-html";
import { css } from "@codemirror/lang-css"; import { css } from "@codemirror/lang-css";
import { markdown } from "@codemirror/lang-markdown";
import { autocompletion } from "@codemirror/autocomplete"; import { autocompletion } from "@codemirror/autocomplete";
import { abbreviationTracker, expandAbbreviation } from "@emmetio/codemirror6-plugin"; import { abbreviationTracker, expandAbbreviation } from "@emmetio/codemirror6-plugin";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { tags } from "@lezer/highlight"; 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( const crispyTheme = EditorView.theme(
{ {
"&": { "&": {
@@ -20,10 +21,10 @@ const crispyTheme = EditorView.theme(
color: "#c8c8d0" color: "#c8c8d0"
}, },
".cm-content": { ".cm-content": {
caretColor: "#9b6dd4" caretColor: "#d46d9b"
}, },
".cm-cursor, .cm-dropCursor": { ".cm-cursor, .cm-dropCursor": {
borderLeftColor: "#9b6dd4" borderLeftColor: "#d46d9b"
}, },
"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": { "&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
backgroundColor: "#3e3e4a" backgroundColor: "#3e3e4a"
@@ -34,10 +35,10 @@ const crispyTheme = EditorView.theme(
}, },
".cm-searchMatch": { ".cm-searchMatch": {
backgroundColor: "#3e3e4a", backgroundColor: "#3e3e4a",
outline: "1px solid #9b6dd4" outline: "1px solid #d46d9b"
}, },
".cm-searchMatch.cm-searchMatch-selected": { ".cm-searchMatch.cm-searchMatch-selected": {
backgroundColor: "rgba(155, 109, 212, 0.3)" backgroundColor: "rgba(212, 109, 155, 0.3)"
}, },
".cm-activeLine": { ".cm-activeLine": {
backgroundColor: "#2e2e3a" backgroundColor: "#2e2e3a"
@@ -62,13 +63,13 @@ const crispyTheme = EditorView.theme(
// Default syntax highlighting (blue accent) // Default syntax highlighting (blue accent)
const defaultHighlight = HighlightStyle.define([ const defaultHighlight = HighlightStyle.define([
{ tag: tags.keyword, color: "#c9a6eb" }, { tag: tags.keyword, color: "#eba6c9" },
{ tag: tags.operator, color: "#cdd6f4" }, { tag: tags.operator, color: "#cdd6f4" },
{ tag: tags.variableName, color: "#89b4fa" }, { tag: tags.variableName, color: "#89b4fa" },
{ tag: tags.propertyName, color: "#89b4fa" }, { tag: tags.propertyName, color: "#89b4fa" },
{ tag: tags.attributeName, color: "#89b4fa" }, { tag: tags.attributeName, color: "#89b4fa" },
{ tag: tags.className, color: "#89b4fa" }, { tag: tags.className, color: "#89b4fa" },
{ tag: tags.tagName, color: "#c9a6eb" }, { tag: tags.tagName, color: "#eba6c9" },
{ tag: tags.string, color: "#a6e3a1" }, { tag: tags.string, color: "#a6e3a1" },
{ tag: tags.number, color: "#fab387" }, { tag: tags.number, color: "#fab387" },
{ tag: tags.bool, color: "#fab387" }, { tag: tags.bool, color: "#fab387" },
@@ -78,20 +79,20 @@ const defaultHighlight = HighlightStyle.define([
{ tag: tags.punctuation, color: "#cdd6f4" }, { tag: tags.punctuation, color: "#cdd6f4" },
{ tag: tags.definition(tags.variableName), color: "#89b4fa" }, { tag: tags.definition(tags.variableName), color: "#89b4fa" },
{ tag: tags.function(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.unit, color: "#a6e3a1" },
{ tag: tags.color, color: "#f9e2af" } { tag: tags.color, color: "#f9e2af" }
]); ]);
// CSS section highlighting (purple selectors) // CSS section highlighting (pink selectors)
const cssHighlight = HighlightStyle.define([ const cssHighlight = HighlightStyle.define([
{ tag: tags.keyword, color: "#c9a6eb" }, { tag: tags.keyword, color: "#eba6c9" },
{ tag: tags.operator, color: "#cdd6f4" }, { tag: tags.operator, color: "#cdd6f4" },
{ tag: tags.variableName, color: "#c9a6eb" }, { tag: tags.variableName, color: "#eba6c9" },
{ tag: tags.propertyName, color: "#89b4fa" }, { tag: tags.propertyName, color: "#89b4fa" },
{ tag: tags.attributeName, color: "#89b4fa" }, { tag: tags.attributeName, color: "#89b4fa" },
{ tag: tags.className, color: "#c9a6eb" }, { tag: tags.className, color: "#eba6c9" },
{ tag: tags.tagName, color: "#c9a6eb" }, { tag: tags.tagName, color: "#eba6c9" },
{ tag: tags.string, color: "#a6e3a1" }, { tag: tags.string, color: "#a6e3a1" },
{ tag: tags.number, color: "#fab387" }, { tag: tags.number, color: "#fab387" },
{ tag: tags.bool, color: "#fab387" }, { tag: tags.bool, color: "#fab387" },
@@ -99,9 +100,9 @@ const cssHighlight = HighlightStyle.define([
{ tag: tags.comment, color: "#6c7086", fontStyle: "italic" }, { tag: tags.comment, color: "#6c7086", fontStyle: "italic" },
{ tag: tags.bracket, color: "#cdd6f4" }, { tag: tags.bracket, color: "#cdd6f4" },
{ tag: tags.punctuation, 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.function(tags.variableName), color: "#89b4fa" },
{ tag: tags.atom, color: "#c9a6eb" }, { tag: tags.atom, color: "#eba6c9" },
{ tag: tags.unit, color: "#a6e3a1" }, { tag: tags.unit, color: "#a6e3a1" },
{ tag: tags.color, color: "#f9e2af" } { tag: tags.color, color: "#f9e2af" }
]); ]);
@@ -146,17 +147,134 @@ export class CodeEditor {
this.mode = options.mode || "css"; this.mode = options.mode || "css";
this.section = options.section || null; this.section = options.section || null;
this.onChange = options.onChange || (() => {}); this.onChange = options.onChange || (() => {});
// Read-only zones support
this.prefixLength = 0;
this.suffixLength = 0;
this.currentPrefix = "";
this.currentSuffix = "";
this.readOnlyCompartment = new Compartment();
} }
/** /**
* Initialize the editor * Initialize the editor (backwards compatible wrapper)
*/ */
init(initialValue = "") { init(initialValue = "") {
return this.initWithContext("", initialValue, "");
}
/**
* Initialize the editor with read-only prefix/suffix zones
* @param {string} prefix - Read-only prefix text (e.g., ".card {\n ")
* @param {string} initialValue - Editable user code
* @param {string} suffix - Read-only suffix text (e.g., "\n}")
*/
initWithContext(prefix = "", initialValue = "", suffix = "") {
// Clear container // Clear container
this.container.innerHTML = ""; this.container.innerHTML = "";
// Store prefix/suffix for re-initialization (e.g., when mode changes)
this.currentPrefix = prefix;
this.currentSuffix = suffix;
this.prefixLength = prefix.length;
this.suffixLength = suffix.length;
const fullDoc = prefix + initialValue + suffix;
// Get language extension based on mode // Get language extension based on mode
const langExtension = this.mode === "html" ? html() : css(); const langExtension = this.mode === "html" ? html() : this.mode === "markdown" ? markdown() : css();
// Create read-only zones decorations
const readOnlyMark = Decoration.mark({ class: "cm-readonly-zone" });
// StateField to track and provide decorations for read-only zones
const readOnlyDecorations = StateField.define({
create: (state) => {
const decorations = [];
if (this.prefixLength > 0) {
decorations.push(readOnlyMark.range(0, this.prefixLength));
}
if (this.suffixLength > 0) {
const suffixStart = state.doc.length - this.suffixLength;
decorations.push(readOnlyMark.range(suffixStart, state.doc.length));
}
return Decoration.set(decorations);
},
update: (decorations, tr) => {
if (!tr.docChanged) return decorations;
// Recalculate decorations after document changes
const newDecorations = [];
if (this.prefixLength > 0) {
newDecorations.push(readOnlyMark.range(0, this.prefixLength));
}
if (this.suffixLength > 0) {
const suffixStart = tr.state.doc.length - this.suffixLength;
newDecorations.push(readOnlyMark.range(suffixStart, tr.state.doc.length));
}
return Decoration.set(newDecorations);
},
provide: (f) => EditorView.decorations.from(f)
});
// Change filter to prevent edits in read-only zones
const readOnlyFilter = EditorState.changeFilter.of((tr) => {
// If no prefix/suffix, allow all changes
if (this.prefixLength === 0 && this.suffixLength === 0) {
return true;
}
const prefixEnd = this.prefixLength;
const suffixStart = tr.startState.doc.length - this.suffixLength;
// Check all change ranges - allow only changes within [prefixEnd, suffixStart]
let blocked = false;
tr.changes.iterChangedRanges((fromA, toA) => {
// Block if change starts in prefix zone
if (fromA < prefixEnd) {
blocked = true;
}
// Block if change extends into suffix zone
if (toA > suffixStart) {
blocked = true;
}
});
return !blocked;
});
// Transaction filter to constrain cursor/selection to editable area
const cursorFilter = EditorState.transactionFilter.of((tr) => {
// If no prefix/suffix, no constraints needed
if (this.prefixLength === 0 && this.suffixLength === 0) {
return tr;
}
const prefixEnd = this.prefixLength;
const suffixStart = tr.newDoc.length - this.suffixLength;
// Check if selection needs adjustment
const selection = tr.newSelection;
let needsAdjustment = false;
for (const range of selection.ranges) {
if (range.from < prefixEnd || range.to > suffixStart) {
needsAdjustment = true;
break;
}
}
if (!needsAdjustment) {
return tr;
}
// Clamp selection to editable area
const newRanges = selection.ranges.map((range) => {
const from = Math.max(prefixEnd, Math.min(suffixStart, range.from));
const to = Math.max(prefixEnd, Math.min(suffixStart, range.to));
return EditorSelection.range(from, to);
});
return [tr, { selection: EditorSelection.create(newRanges, selection.mainIndex) }];
});
// Build extensions array // Build extensions array
const extensions = [ const extensions = [
@@ -165,6 +283,10 @@ export class CodeEditor {
editorTheme, editorTheme,
// History for undo/redo // History for undo/redo
history(), history(),
// Read-only zones (decorations, change filter, and cursor constraint)
readOnlyDecorations,
readOnlyFilter,
cursorFilter,
// Emmet abbreviation tracking // Emmet abbreviation tracking
abbreviationTracker(), abbreviationTracker(),
// High priority keymap for Emmet // High priority keymap for Emmet
@@ -184,20 +306,21 @@ export class CodeEditor {
}), }),
EditorView.updateListener.of((update) => { EditorView.updateListener.of((update) => {
if (update.docChanged) { if (update.docChanged) {
this.onChange(this.getValue()); // Report only the editable portion to the onChange handler
this.onChange(this.getEditableValue());
} }
}), }),
EditorView.lineWrapping EditorView.lineWrapping
]; ];
// Add placeholder if provided // Add placeholder if provided (only makes sense when no prefix/suffix)
if (this.options.placeholder) { if (this.options.placeholder && this.prefixLength === 0 && this.suffixLength === 0) {
extensions.push(placeholder(this.options.placeholder)); extensions.push(placeholder(this.options.placeholder));
} }
// Create editor state // Create editor state
const state = EditorState.create({ const state = EditorState.create({
doc: initialValue, doc: fullDoc,
extensions extensions
}); });
@@ -207,26 +330,47 @@ export class CodeEditor {
parent: this.container parent: this.container
}); });
// Position cursor at start of editable area
if (this.prefixLength > 0) {
this.view.dispatch({
selection: { anchor: this.prefixLength }
});
}
return this; return this;
} }
/** /**
* Get current editor value * Get current full editor value (including prefix/suffix)
*/ */
getValue() { getValue() {
return this.view ? this.view.state.doc.toString() : ""; return this.view ? this.view.state.doc.toString() : "";
} }
/** /**
* Set editor value (preserves history) * Get only the editable portion (excluding prefix/suffix)
*/
getEditableValue() {
if (!this.view) return "";
const fullText = this.view.state.doc.toString();
const editableEnd = fullText.length - this.suffixLength;
return fullText.slice(this.prefixLength, editableEnd);
}
/**
* Set editor value in the editable zone only (preserves history)
*/ */
setValue(value) { setValue(value) {
if (!this.view) return; if (!this.view) return;
// Only replace the editable portion
const editableStart = this.prefixLength;
const editableEnd = this.view.state.doc.length - this.suffixLength;
this.view.dispatch({ this.view.dispatch({
changes: { changes: {
from: 0, from: editableStart,
to: this.view.state.doc.length, to: editableEnd,
insert: value insert: value
} }
}); });
@@ -234,9 +378,12 @@ export class CodeEditor {
/** /**
* Set editor value and clear history (for lesson switching) * Set editor value and clear history (for lesson switching)
* @param {string} value - The editable user code (not including prefix/suffix)
* @param {string} prefix - Optional read-only prefix
* @param {string} suffix - Optional read-only suffix
*/ */
setValueAndClearHistory(value) { setValueAndClearHistory(value, prefix = "", suffix = "") {
this.init(value); this.initWithContext(prefix, value, suffix);
} }
/** /**
@@ -246,8 +393,8 @@ export class CodeEditor {
if (this.mode === mode) return; if (this.mode === mode) return;
this.mode = mode; this.mode = mode;
const currentValue = this.getValue(); const editableValue = this.getEditableValue();
this.init(currentValue); this.initWithContext(this.currentPrefix, editableValue, this.currentSuffix);
} }
/** /**
@@ -257,8 +404,8 @@ export class CodeEditor {
if (this.section === section) return; if (this.section === section) return;
this.section = section; this.section = section;
const currentValue = this.getValue(); const editableValue = this.getEditableValue();
this.init(currentValue); this.initWithContext(this.currentPrefix, editableValue, this.currentSuffix);
} }
/** /**

View File

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

View File

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

View File

@@ -1,15 +1,15 @@
/* ================= BASE THEME ================= */ /* ================= BASE THEME ================= */
:root { :root {
/* Primary colors */ /* Primary colors */
--primary-color: #5e4b8b; --primary-color: #c9507a;
--primary-light: #8a77b5; --primary-light: #e077a0;
--primary-dark: #724a95; --primary-dark: #a83d65;
/* Section colors (default to CSS purple) */ /* Section colors (default to CSS pink) */
--section-color: #9163b8; --section-color: #d95a8a;
--section-color-light: #a87dc8; --section-color-light: #e87da6;
--section-color-dark: #724a95; --section-color-dark: #b84472;
--section-color-rgb: 145, 99, 184; --section-color-rgb: 217, 90, 138;
/* Secondary colors */ /* Secondary colors */
--secondary-color: #444444; --secondary-color: #444444;
@@ -23,9 +23,9 @@
--white-text: #ffffff; --white-text: #ffffff;
/* Background colors */ /* Background colors */
--bg-color: #f8f7fc; --bg-color: #fcf7f9;
--panel-bg: #ffffff; --panel-bg: #ffffff;
--code-bg: #f7f5fa; --code-bg: #faf5f7;
--editor-bg: #1e1e1e; --editor-bg: #1e1e1e;
--editor-highlight: #303030; --editor-highlight: #303030;
@@ -34,9 +34,9 @@
/* Status colors */ /* Status colors */
--info-color: #7a93fe; --info-color: #7a93fe;
--success-color: #9b6dd4; --success-color: #d46d9b;
--success-color-dark: #7c4dff; --success-color-dark: #b84472;
--success-color-light: #c9b8e8; --success-color-light: #e8b8d0;
--error-color: #cb6e75; --error-color: #cb6e75;
--danger-color: #dc3545; --danger-color: #dc3545;
@@ -252,11 +252,11 @@ kbd {
} }
.logo h1 .code-text { .logo h1 .code-text {
color: #9163b8; color: #d95a8a;
} }
.logo h1 .crispies-text { .logo h1 .crispies-text {
background: #9163b8; background: #d95a8a;
color: white; color: white;
padding: 0.15rem 0.35rem; padding: 0.15rem 0.35rem;
border-radius: 4px; border-radius: 4px;
@@ -283,6 +283,14 @@ kbd {
background: #1aafb8; background: #1aafb8;
} }
[data-section="markdown"] .logo h1 .code-text {
color: #5b8dd9;
}
[data-section="markdown"] .logo h1 .crispies-text {
background: #5b8dd9;
}
.help-toggle { .help-toggle {
width: 28px; width: 28px;
height: 28px; height: 28px;
@@ -308,6 +316,16 @@ kbd {
gap: var(--spacing-sm); gap: var(--spacing-sm);
} }
#auth-trigger-header {
display: none;
}
@media (min-width: 769px) {
#auth-trigger-header {
display: inline-flex;
}
}
/* ================= GAME LAYOUT ================= */ /* ================= GAME LAYOUT ================= */
.game-layout { .game-layout {
display: flex; display: flex;
@@ -374,6 +392,7 @@ kbd {
gap: 0.5rem; gap: 0.5rem;
background: var(--primary-bg-medium); background: var(--primary-bg-medium);
color: var(--primary-color); color: var(--primary-color);
min-width: 0;
padding: 4px 12px; padding: 4px 12px;
border-radius: 16px; border-radius: 16px;
font-size: 0.8rem; font-size: 0.8rem;
@@ -385,12 +404,18 @@ kbd {
.module-name { .module-name {
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
} }
.module-pill .level-indicator { .module-pill .level-indicator {
color: var(--primary-dark); color: var(--primary-dark);
font-weight: 500; font-weight: 500;
opacity: 0.8; opacity: 0.8;
white-space: nowrap;
flex-shrink: 0;
} }
.lesson-title-row { .lesson-title-row {
@@ -398,7 +423,13 @@ kbd {
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: var(--spacing-sm); margin-bottom: var(--spacing-sm);
flex-wrap: wrap; }
.lesson-title-row .difficulty-wrapper {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
} }
#lesson-title { #lesson-title {
@@ -437,7 +468,7 @@ kbd {
.completion-badge { .completion-badge {
display: inline-block; display: inline-block;
padding: 0.15rem 0.5rem; padding: 0.15rem 0.5rem;
background: linear-gradient(135deg, #9163b8, #d45aa0, #1aafb8, #7c4dff); background: linear-gradient(135deg, #d95a8a, #d45aa0, #1aafb8, #ff4d88);
color: white; color: white;
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 600; font-weight: 600;
@@ -447,6 +478,28 @@ kbd {
vertical-align: middle; vertical-align: middle;
} }
.difficulty-badge {
display: inline-flex;
align-items: flex-end;
gap: 2px;
vertical-align: middle;
}
.difficulty-badge .bar {
width: 3px;
border-radius: 1px;
background: var(--border-color);
}
.difficulty-badge .bar:nth-child(1) { height: 6px; }
.difficulty-badge .bar:nth-child(2) { height: 9px; }
.difficulty-badge .bar:nth-child(3) { height: 12px; }
.difficulty-easy .bar:nth-child(1),
.difficulty-medium .bar:nth-child(1),
.difficulty-medium .bar:nth-child(2),
.difficulty-hard .bar { background: var(--light-text); }
.lesson-description { .lesson-description {
font-size: 0.95rem; font-size: 0.95rem;
line-height: 1.6; line-height: 1.6;
@@ -548,6 +601,12 @@ kbd {
height: 100%; height: 100%;
} }
/* Read-only zones (codePrefix/codeSuffix) */
.cm-readonly-zone {
opacity: 0.5;
background: rgba(100, 100, 120, 0.1);
}
.editor-content .cm-scroller { .editor-content .cm-scroller {
overflow: auto; overflow: auto;
} }
@@ -655,7 +714,7 @@ kbd {
position: absolute; position: absolute;
inset: var(--spacing-md); inset: var(--spacing-md);
border-radius: var(--border-radius-md); border-radius: var(--border-radius-md);
background: conic-gradient(from var(--border-angle), #9163b8, #d45aa0, #1aafb8, #7c4dff, #9163b8); background: conic-gradient(from var(--border-angle), #d95a8a, #d45aa0, #1aafb8, #ff4d88, #d95a8a);
filter: blur(30px); filter: blur(30px);
opacity: 0; opacity: 0;
animation: spin-glow 3s ease-out forwards; animation: spin-glow 3s ease-out forwards;
@@ -668,7 +727,7 @@ kbd {
position: absolute; position: absolute;
inset: var(--spacing-md); inset: var(--spacing-md);
border-radius: var(--border-radius-md); border-radius: var(--border-radius-md);
background: conic-gradient(from 0deg, #9163b8, #d45aa0, #1aafb8, #7c4dff, #9163b8); background: conic-gradient(from 0deg, #d95a8a, #d45aa0, #1aafb8, #ff4d88, #d95a8a);
filter: blur(30px); filter: blur(30px);
opacity: 0.35; opacity: 0.35;
pointer-events: none; pointer-events: none;
@@ -757,7 +816,7 @@ kbd {
border: 6px solid transparent; border: 6px solid transparent;
background: background:
linear-gradient(var(--panel-bg), var(--panel-bg)) padding-box, linear-gradient(var(--panel-bg), var(--panel-bg)) padding-box,
conic-gradient(from 0deg, #9163b8, #d45aa0, #1aafb8, #7c4dff, #9163b8) border-box; conic-gradient(from 0deg, #d95a8a, #d45aa0, #1aafb8, #ff4d88, #d95a8a) border-box;
} }
.preview-wrapper.matched { .preview-wrapper.matched {
@@ -765,7 +824,7 @@ kbd {
border: 6px solid transparent; border: 6px solid transparent;
background: background:
linear-gradient(var(--panel-bg), var(--panel-bg)) padding-box, linear-gradient(var(--panel-bg), var(--panel-bg)) padding-box,
conic-gradient(from var(--border-angle), #9163b8, #d45aa0, #1aafb8, #7c4dff, #9163b8) border-box; conic-gradient(from var(--border-angle), #d95a8a, #d45aa0, #1aafb8, #ff4d88, #d95a8a) border-box;
animation: spin-border 3s ease-out forwards; animation: spin-border 3s ease-out forwards;
overflow: visible; overflow: visible;
} }
@@ -785,7 +844,7 @@ kbd {
font-weight: 800; font-weight: 800;
letter-spacing: 0.05em; letter-spacing: 0.05em;
color: white; color: white;
background: linear-gradient(135deg, #9163b8 0%, #d45aa0 50%, #7c4dff 100%); background: linear-gradient(135deg, #d95a8a 0%, #d45aa0 50%, #ff4d88 100%);
padding: 1.25rem 2rem 1.75rem; padding: 1.25rem 2rem 1.75rem;
z-index: 10; z-index: 10;
pointer-events: none; pointer-events: none;
@@ -893,8 +952,9 @@ kbd {
/* ================= GAME CONTROLS ================= */ /* ================= GAME CONTROLS ================= */
.game-controls { .game-controls {
display: flex; display: grid;
justify-content: space-between; grid-template-columns: 1fr auto 1fr;
gap: var(--spacing-md);
align-items: center; align-items: center;
padding: var(--spacing-md); padding: var(--spacing-md);
background: var(--panel-bg); background: var(--panel-bg);
@@ -902,8 +962,16 @@ kbd {
box-shadow: 0 10px 20px -10px rgba(0, 0, 0, 0.08); box-shadow: 0 10px 20px -10px rgba(0, 0, 0, 0.08);
} }
.game-controls.centered { .game-controls #prev-btn {
justify-content: center; justify-self: start;
}
.game-controls .module-pill {
justify-self: center;
}
.game-controls #next-btn {
justify-self: end;
} }
/* ================= SIDEBAR ================= */ /* ================= SIDEBAR ================= */
@@ -987,14 +1055,69 @@ kbd {
border-bottom: none; border-bottom: none;
} }
/* Mobile navigation in sidebar */
.sidebar-nav-mobile {
display: flex;
flex-direction: column;
gap: 0;
}
.sidebar-nav-link {
display: block;
padding: 0.6rem var(--spacing-md);
font-size: 0.9rem;
font-weight: 500;
text-decoration: none;
color: var(--text-color);
border-bottom: 1px solid var(--border-color);
}
.sidebar-nav-link:last-child {
border-bottom: none;
}
.sidebar-nav-link:hover {
background: var(--primary-bg-light);
color: var(--primary-color);
}
.sidebar-auth-link {
width: calc(100% - 2 * var(--spacing-md));
margin: var(--spacing-sm) var(--spacing-md);
padding: 0.5rem 1rem;
text-align: center;
background: var(--primary-color);
color: var(--white-text);
border: none;
border-radius: var(--border-radius-md);
cursor: pointer;
font-family: inherit;
font-weight: 500;
}
.sidebar-auth-link:hover {
background: var(--primary-dark);
}
@media (min-width: 769px) {
.sidebar-nav-mobile {
display: none;
}
}
/* Make the lessons nav section fill available space */ /* Make the lessons nav section fill available space */
nav.sidebar-section { nav.sidebar-section:not(.sidebar-nav-mobile) {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
min-height: 0; min-height: 0;
padding-bottom: var(--spacing-md); padding-bottom: var(--spacing-md);
} }
.sidebar-nav-mobile {
flex: none;
padding: 0;
}
.sidebar-section h4 { .sidebar-section h4 {
font-size: 0.75rem; font-size: 0.75rem;
text-transform: uppercase; text-transform: uppercase;
@@ -1019,7 +1142,7 @@ nav.sidebar-section {
.progress-fill { .progress-fill {
height: 100%; height: 100%;
background: linear-gradient(90deg, #9163b8, #d45aa0, #1aafb8, #7c4dff); background: linear-gradient(90deg, #d95a8a, #d45aa0, #1aafb8, #ff4d88);
background-size: calc(100% * 100 / var(--progress-percent, 100)) 100%; background-size: calc(100% * 100 / var(--progress-percent, 100)) 100%;
border-radius: 4px; border-radius: 4px;
transition: width 0.3s ease; transition: width 0.3s ease;
@@ -1031,6 +1154,28 @@ nav.sidebar-section {
color: var(--light-text); color: var(--light-text);
} }
.progress-bar-row {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.progress-bar-row .progress-bar {
flex: 1;
}
.progress-current {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-color);
white-space: nowrap;
}
.progress-total {
font-size: 0.75rem;
color: var(--light-text);
}
/* Milestone Progress */ /* Milestone Progress */
.milestone-progress { .milestone-progress {
gap: var(--spacing-sm); gap: var(--spacing-sm);
@@ -1060,20 +1205,21 @@ nav.sidebar-section {
color: white; color: white;
} }
/* Each milestone gets a portion of the gradient based on position */ /* Each milestone gets a color evenly distributed across the gradient
.milestone.reached:nth-child(1) { background: #9163b8; } Gradient: #d95a8a (0%) → #d45aa0 (33%) → #1aafb8 (67%) → #ff4d88 (100%) */
.milestone.reached:nth-child(2) { background: linear-gradient(135deg, #9163b8, #a85dac); } .milestone.reached:nth-child(1) { background: #a55eac; } /* ~14% */
.milestone.reached:nth-child(3) { background: linear-gradient(135deg, #9163b8, #d45aa0); } .milestone.reached:nth-child(2) { background: #c459a2; } /* ~28% */
.milestone.reached:nth-child(4) { background: linear-gradient(135deg, #9163b8, #d45aa0, #e87aac); } .milestone.reached:nth-child(3) { background: #d45aa0; } /* ~33% pink */
.milestone.reached:nth-child(5) { background: linear-gradient(135deg, #9163b8, #d45aa0, #1aafb8); } .milestone.reached:nth-child(4) { background: #a874a8; } /* ~43% */
.milestone.reached:nth-child(6) { background: linear-gradient(135deg, #9163b8, #d45aa0, #1aafb8, #4b8ecc); } .milestone.reached:nth-child(5) { background: #7785ac; } /* ~50% */
.milestone.reached:nth-child(7) { background: linear-gradient(135deg, #9163b8, #d45aa0, #1aafb8, #7c4dff); } .milestone.reached:nth-child(6) { background: #33a3b6; } /* ~62% */
.milestone.reached:nth-child(8) { background: linear-gradient(135deg, #9163b8, #d45aa0, #1aafb8, #7c4dff); } .milestone.reached:nth-child(7) { background: #4889d8; } /* ~80% */
.milestone.reached:nth-child(8) { background: #ff4d88; } /* 100% */
.milestone.current { .milestone.current {
color: white; color: white;
transform: scale(1.15); transform: scale(1.15);
box-shadow: 0 2px 8px rgba(145, 99, 184, 0.4); box-shadow: 0 2px 8px rgba(217, 90, 138, 0.4);
} }
.milestone.next { .milestone.next {
@@ -1357,8 +1503,63 @@ button.lesson-list-item {
display: none; display: none;
} }
/* ================= TOGGLE SWITCH ================= */ /* ================= SETTINGS CARD ================= */
/* Setting row (for label + control) */ .settings-card {
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-md);
overflow: hidden;
}
.settings-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.settings-row:last-child {
border-bottom: none;
}
.settings-label {
font-size: 0.9rem;
color: var(--text-color);
}
.settings-toggle {
width: 40px;
height: 22px;
appearance: none;
background: var(--border-color);
border-radius: 11px;
position: relative;
cursor: pointer;
transition: background 0.2s;
}
.settings-toggle::before {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
background: white;
border-radius: 50%;
transition: transform 0.2s;
}
.settings-toggle:checked {
background: var(--primary-color);
}
.settings-toggle:checked::before {
transform: translateX(18px);
}
/* Legacy setting row (for label + control) */
.setting-row { .setting-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2388,17 +2589,11 @@ input:checked + .toggle-slider::before {
.device-notice { .device-notice {
margin-top: var(--spacing-lg); margin-top: var(--spacing-lg);
text-align: center; text-align: center;
padding: 1rem;
}
.device-notice p {
display: inline-block;
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, rgba(145, 99, 184, 0.1), rgba(212, 90, 160, 0.1), rgba(26, 175, 184, 0.1)); background: linear-gradient(135deg, rgba(217, 90, 138, 0.1), rgba(212, 90, 160, 0.1), rgba(26, 175, 184, 0.1));
border-radius: var(--border-radius-md); border-radius: var(--border-radius-md);
color: var(--light-text); color: var(--light-text);
font-size: 0.9rem; font-size: 0.9rem;
margin: 0;
} }
.device-notice strong { .device-notice strong {
@@ -2645,7 +2840,7 @@ input:checked + .toggle-slider::before {
} }
.section-overview code { .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)); color: var(--section-color-dark, var(--primary-dark));
padding: 0.1rem 0.35rem; padding: 0.1rem 0.35rem;
border-radius: 4px; border-radius: 4px;
@@ -2755,7 +2950,7 @@ input:checked + .toggle-slider::before {
/* Inline code in topic text */ /* Inline code in topic text */
.topic-text code { .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)); color: var(--section-color-dark, var(--primary-dark));
padding: 0.15rem 0.4rem; padding: 0.15rem 0.4rem;
border-radius: 4px; border-radius: 4px;
@@ -3152,11 +3347,16 @@ input:checked + .toggle-slider::before {
.module-pill { .module-pill {
flex: 1; flex: 1;
min-width: 0;
max-width: 100%;
margin: 0 var(--spacing-sm); margin: 0 var(--spacing-sm);
justify-content: center; justify-content: center;
overflow: hidden;
} }
.module-name { .module-name {
flex: 1;
min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -3392,7 +3592,7 @@ input:checked + .toggle-slider::before {
} }
/* ================= SECTION COLOR CODING ================= */ /* ================= SECTION COLOR CODING ================= */
/* CSS Section uses default purple from :root */ /* CSS Section uses default pink from :root */
/* HTML Section - Pink (balanced) */ /* HTML Section - Pink (balanced) */
[data-section="html"] { [data-section="html"] {
@@ -3410,9 +3610,17 @@ input:checked + .toggle-slider::before {
--section-color-rgb: 26, 175, 184; --section-color-rgb: 26, 175, 184;
} }
/* Markdown Section - Blue */
[data-section="markdown"] {
--section-color: #5b8dd9;
--section-color-light: #7ba3e5;
--section-color-dark: #4070b8;
--section-color-rgb: 91, 141, 217;
}
/* Apply section colors to nav links */ /* Apply section colors to nav links */
.nav-link[data-section="css"] { .nav-link[data-section="css"] {
color: #9163b8; color: #d95a8a;
} }
.nav-link[data-section="html"] { .nav-link[data-section="html"] {
@@ -3423,10 +3631,14 @@ input:checked + .toggle-slider::before {
color: #1aafb8; color: #1aafb8;
} }
.nav-link[data-section="markdown"] {
color: #5b8dd9;
}
.nav-link[data-section="css"]:hover, .nav-link[data-section="css"]:hover,
.nav-link[data-section="css"].active { .nav-link[data-section="css"].active {
background: rgba(145, 99, 184, 0.1); background: rgba(217, 90, 138, 0.1);
color: #724a95; color: #a83d65;
} }
.nav-link[data-section="html"]:hover, .nav-link[data-section="html"]:hover,
@@ -3441,14 +3653,20 @@ input:checked + .toggle-slider::before {
color: #0d8f96; color: #0d8f96;
} }
.nav-link[data-section="markdown"]:hover,
.nav-link[data-section="markdown"].active {
background: rgba(91, 141, 217, 0.1);
color: #4070b8;
}
/* Hint section colors */ /* Hint section colors */
body[data-section="css"] .hint { body[data-section="css"] .hint {
background: rgba(145, 99, 184, 0.3); background: rgba(217, 90, 138, 0.3);
border-left-color: #a98cd6; border-left-color: #a98cd6;
} }
body[data-section="css"] .hint-progress { body[data-section="css"] .hint-progress {
background: #9163b8; background: #d95a8a;
} }
body[data-section="html"] .hint { body[data-section="html"] .hint {
@@ -3469,6 +3687,15 @@ body[data-section="tailwind"] .hint-progress {
background: #1aafb8; background: #1aafb8;
} }
body[data-section="markdown"] .hint {
background: rgba(91, 141, 217, 0.3);
border-left-color: #7ba3e5;
}
body[data-section="markdown"] .hint-progress {
background: #5b8dd9;
}
/* RTL hint border */ /* RTL hint border */
[dir="rtl"] body[data-section="css"] .hint { [dir="rtl"] body[data-section="css"] .hint {
border-right-color: #a98cd6; border-right-color: #a98cd6;
@@ -3482,12 +3709,16 @@ body[data-section="tailwind"] .hint-progress {
border-right-color: #4db6ac; border-right-color: #4db6ac;
} }
[dir="rtl"] body[data-section="markdown"] .hint {
border-right-color: #7ba3e5;
}
/* Reference nav link colors */ /* Reference nav link colors */
.ref-nav-link[data-ref="css"], .ref-nav-link[data-ref="css"],
.ref-nav-link[data-ref="selectors"], .ref-nav-link[data-ref="selectors"],
.ref-nav-link[data-ref="flexbox"], .ref-nav-link[data-ref="flexbox"],
.ref-nav-link[data-ref="grid"] { .ref-nav-link[data-ref="grid"] {
color: #9163b8; color: #d95a8a;
} }
.ref-nav-link[data-ref="css"]:hover, .ref-nav-link[data-ref="css"]:hover,
@@ -3498,8 +3729,8 @@ body[data-section="tailwind"] .hint-progress {
.ref-nav-link[data-ref="flexbox"].active, .ref-nav-link[data-ref="flexbox"].active,
.ref-nav-link[data-ref="grid"]:hover, .ref-nav-link[data-ref="grid"]:hover,
.ref-nav-link[data-ref="grid"].active { .ref-nav-link[data-ref="grid"].active {
background: rgba(145, 99, 184, 0.15); background: rgba(217, 90, 138, 0.15);
color: #724a95; color: #a83d65;
} }
.ref-nav-link[data-ref="html"] { .ref-nav-link[data-ref="html"] {
@@ -3514,21 +3745,21 @@ body[data-section="tailwind"] .hint-progress {
/* CodeMirror section color overrides */ /* CodeMirror section color overrides */
body[data-section="css"] .cm-editor .cm-content { body[data-section="css"] .cm-editor .cm-content {
caret-color: #9163b8 !important; caret-color: #d95a8a !important;
} }
body[data-section="css"] .cm-editor .cm-cursor, body[data-section="css"] .cm-editor .cm-cursor,
body[data-section="css"] .cm-editor .cm-dropCursor { body[data-section="css"] .cm-editor .cm-dropCursor {
border-left-color: #9163b8 !important; border-left-color: #d95a8a !important;
} }
body[data-section="css"] .cm-editor .cm-selectionBackground, body[data-section="css"] .cm-editor .cm-selectionBackground,
body[data-section="css"] .cm-editor .cm-content ::selection { body[data-section="css"] .cm-editor .cm-content ::selection {
background-color: rgba(145, 99, 184, 0.25) !important; background-color: rgba(217, 90, 138, 0.25) !important;
} }
body[data-section="css"] .cm-editor .cm-activeLine { body[data-section="css"] .cm-editor .cm-activeLine {
background-color: rgba(145, 99, 184, 0.08) !important; background-color: rgba(217, 90, 138, 0.08) !important;
} }
body[data-section="html"] .cm-editor .cm-content { body[data-section="html"] .cm-editor .cm-content {
@@ -3567,14 +3798,32 @@ body[data-section="tailwind"] .cm-editor .cm-activeLine {
background-color: rgba(26, 175, 184, 0.08) !important; background-color: rgba(26, 175, 184, 0.08) !important;
} }
body[data-section="markdown"] .cm-editor .cm-content {
caret-color: #5b8dd9 !important;
}
body[data-section="markdown"] .cm-editor .cm-cursor,
body[data-section="markdown"] .cm-editor .cm-dropCursor {
border-left-color: #5b8dd9 !important;
}
body[data-section="markdown"] .cm-editor .cm-selectionBackground,
body[data-section="markdown"] .cm-editor .cm-content ::selection {
background-color: rgba(91, 141, 217, 0.25) !important;
}
body[data-section="markdown"] .cm-editor .cm-activeLine {
background-color: rgba(91, 141, 217, 0.08) !important;
}
/* Module pill section colors */ /* Module pill section colors */
body[data-section="css"] .module-pill { body[data-section="css"] .module-pill {
background: rgba(145, 99, 184, 0.1); background: rgba(217, 90, 138, 0.1);
color: #9163b8; color: #d95a8a;
} }
body[data-section="css"] .module-pill .level-indicator { body[data-section="css"] .module-pill .level-indicator {
color: #724a95; color: #a83d65;
} }
body[data-section="html"] .module-pill { body[data-section="html"] .module-pill {
@@ -3595,9 +3844,18 @@ body[data-section="tailwind"] .module-pill .level-indicator {
color: #0d8f96; color: #0d8f96;
} }
body[data-section="markdown"] .module-pill {
background: rgba(91, 141, 217, 0.1);
color: #5b8dd9;
}
body[data-section="markdown"] .module-pill .level-indicator {
color: #4070b8;
}
/* Code block border section colors */ /* Code block border section colors */
body[data-section="css"] .code-block { body[data-section="css"] .code-block {
border-color: rgba(145, 99, 184, 0.4); border-color: rgba(217, 90, 138, 0.4);
} }
body[data-section="html"] .code-block { body[data-section="html"] .code-block {
@@ -3608,6 +3866,10 @@ body[data-section="tailwind"] .code-block {
border-color: rgba(26, 175, 184, 0.4); border-color: rgba(26, 175, 184, 0.4);
} }
body[data-section="markdown"] .code-block {
border-color: rgba(91, 141, 217, 0.4);
}
/* Section code block CodeMirror syntax highlighting overrides */ /* Section code block CodeMirror syntax highlighting overrides */
body[data-section="css"] .code-block .cm-editor .cm-line { body[data-section="css"] .code-block .cm-editor .cm-line {
color: #c9c0e0; color: #c9c0e0;
@@ -3621,9 +3883,13 @@ body[data-section="tailwind"] .code-block .cm-editor .cm-line {
color: #c0e0e8; color: #c0e0e8;
} }
body[data-section="markdown"] .code-block .cm-editor .cm-line {
color: #c0d0e8;
}
/* Task instruction bubble section colors */ /* Task instruction bubble section colors */
[data-section="css"] .task-instruction { [data-section="css"] .task-instruction {
background: rgba(145, 99, 184, 0.92); background: rgba(217, 90, 138, 0.92);
} }
[data-section="html"] .task-instruction { [data-section="html"] .task-instruction {
@@ -3634,9 +3900,13 @@ body[data-section="tailwind"] .code-block .cm-editor .cm-line {
background: rgba(26, 175, 184, 0.92); background: rgba(26, 175, 184, 0.92);
} }
[data-section="markdown"] .task-instruction {
background: rgba(91, 141, 217, 0.92);
}
/* Section page progress bar colors */ /* Section page progress bar colors */
body[data-section="css"] .section-progress-bar .progress-fill { body[data-section="css"] .section-progress-bar .progress-fill {
background: #9163b8; background: #d95a8a;
} }
body[data-section="html"] .section-progress-bar .progress-fill { body[data-section="html"] .section-progress-bar .progress-fill {
@@ -3647,9 +3917,13 @@ body[data-section="tailwind"] .section-progress-bar .progress-fill {
background: #1aafb8; background: #1aafb8;
} }
body[data-section="markdown"] .section-progress-bar .progress-fill {
background: #5b8dd9;
}
/* Section page header colors */ /* Section page header colors */
[data-section="css"] .section-hero h1 { [data-section="css"] .section-hero h1 {
color: #9163b8; color: #d95a8a;
} }
[data-section="html"] .section-hero h1 { [data-section="html"] .section-hero h1 {
@@ -3660,9 +3934,13 @@ body[data-section="tailwind"] .section-progress-bar .progress-fill {
color: #1aafb8; color: #1aafb8;
} }
[data-section="markdown"] .section-hero h1 {
color: #5b8dd9;
}
/* Lesson title h2 section colors */ /* Lesson title h2 section colors */
body[data-section="css"] #lesson-title { body[data-section="css"] #lesson-title {
color: #9163b8; color: #d95a8a;
} }
body[data-section="html"] #lesson-title { body[data-section="html"] #lesson-title {
@@ -3673,6 +3951,10 @@ body[data-section="tailwind"] #lesson-title {
color: #1aafb8; color: #1aafb8;
} }
body[data-section="markdown"] #lesson-title {
color: #5b8dd9;
}
/* Section and Reference footer - override landing-footer styles */ /* Section and Reference footer - override landing-footer styles */
.section-footer.landing-footer, .section-footer.landing-footer,
.reference-footer.landing-footer { .reference-footer.landing-footer {

View File

@@ -27,7 +27,7 @@ describe("Lessons Config Module", () => {
modules.forEach((module) => { modules.forEach((module) => {
module.lessons.forEach((lesson) => { module.lessons.forEach((lesson) => {
expect(lesson.mode).toBeDefined(); expect(lesson.mode).toBeDefined();
expect(["html", "css", "tailwind", "playground"]).toContain(lesson.mode); expect(["html", "css", "tailwind", "markdown", "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 { describe, test, expect, vi, beforeEach } from "vitest";
import { renderModuleList, renderLesson, renderLevelIndicator, showFeedback, clearFeedback } from "../../src/helpers/renderer.js"; import { renderModuleList, renderLesson, renderLevelIndicator, showFeedback, clearFeedback, computeLessonDifficulty } from "../../src/helpers/renderer.js";
describe("Renderer Module", () => { describe("Renderer Module", () => {
beforeEach(() => { beforeEach(() => {
@@ -176,4 +176,68 @@ describe("Renderer Module", () => {
clearFeedback(); clearFeedback();
}); });
}); });
describe("computeLessonDifficulty", () => {
test("should return 'easy' when codePrefix contains selector", () => {
expect(computeLessonDifficulty({
codePrefix: ".text {\n ",
solution: "color: coral;"
})).toBe("easy");
expect(computeLessonDifficulty({
codePrefix: "h1, h2, h3 {\n ",
solution: "color: steelblue;"
})).toBe("easy");
});
test("should return 'medium' for simple type selector", () => {
expect(computeLessonDifficulty({
codePrefix: "",
solution: "p {\n color: steelblue;\n}"
})).toBe("medium");
expect(computeLessonDifficulty({
codePrefix: "",
solution: "a {\n color: coral;\n}"
})).toBe("medium");
});
test("should return 'medium' for simple class selector", () => {
expect(computeLessonDifficulty({
codePrefix: "",
solution: ".badge {\n background: tomato;\n}"
})).toBe("medium");
});
test("should return 'hard' for descendant selectors", () => {
expect(computeLessonDifficulty({
codePrefix: "",
solution: ".nav a {\n color: white;\n}"
})).toBe("hard");
expect(computeLessonDifficulty({
codePrefix: "",
solution: ".card p {\n font-size: 0.9rem;\n}"
})).toBe("hard");
});
test("should return 'hard' for chained class selectors", () => {
expect(computeLessonDifficulty({
codePrefix: "",
solution: ".btn.primary {\n background: steelblue;\n}"
})).toBe("hard");
});
test("should return 'hard' for type+class selectors", () => {
expect(computeLessonDifficulty({
codePrefix: "",
solution: "a.btn {\n text-decoration: none;\n}"
})).toBe("hard");
});
test("should handle missing fields gracefully", () => {
expect(computeLessonDifficulty({})).toBe("medium");
expect(computeLessonDifficulty({ codePrefix: null })).toBe("medium");
});
});
}); });

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

@@ -0,0 +1,232 @@
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"]
])("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");
});
test("getSectionIds_MutatingCopy_DoesNotAffectOriginal", () => {
const ids = getSectionIds();
ids.push("custom");
const freshIds = getSectionIds();
expect(freshIds).not.toContain("custom");
});
});
});

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

@@ -0,0 +1,172 @@
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_HasFourSections", () => {
expect(Object.keys(sections)).toHaveLength(4);
expect(sections).toHaveProperty("css");
expect(sections).toHaveProperty("html");
expect(sections).toHaveProperty("tailwind");
expect(sections).toHaveProperty("markdown");
});
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"]
])("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(4);
// 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_MarkdownIsLast", () => {
const list = getSectionList();
expect(list[list.length - 1].id).toBe("markdown");
});
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");
});
});
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"]
])("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: "javascript" })).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");
});
});
});

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