Compare commits
32 Commits
feature/ne
...
feat/impl-
| Author | SHA1 | Date | |
|---|---|---|---|
| ae8f9fef45 | |||
| 372320b807 | |||
| 61acd692f4 | |||
| 672a2d28cb | |||
| 433379155b | |||
| 756841f8c2 | |||
| c97fce1f29 | |||
| 8b6a88ad59 | |||
| 4476d26140 | |||
| f28531fb4c | |||
| 7ab095718b | |||
| 5a243f332a | |||
| 739470e045 | |||
| 07aafa0d89 | |||
| eb82eed826 | |||
| 82f6e46d3c | |||
| 847b261f16 | |||
| 2ce88f9cb7 | |||
| a8ef3d3c5c | |||
| 0f5ac81fe8 | |||
| cf0d2cba51 | |||
| d5bd23615f | |||
| fcc6748aae | |||
| 5c16a8a767 | |||
| 17b3d5380d | |||
| f9311d83f7 | |||
| f4ce61ba64 | |||
| 813d669302 | |||
| 9328399dcb | |||
| 857ae9c3ef | |||
| c91e8d6f32 | |||
|
|
9821e014c5 |
@@ -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/**)"]
|
||||||
},
|
},
|
||||||
|
|||||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -7,4 +7,15 @@ coverage
|
|||||||
.env.local
|
.env.local
|
||||||
|
|
||||||
# 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
61
flake.lock
generated
Normal 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
|
||||||
|
}
|
||||||
@@ -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 = ''
|
||||||
|
|||||||
197
lessons/40-markdown-basics.json
Normal file
197
lessons/40-markdown-basics.json
Normal 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>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
139
lessons/50-js-variables.json
Normal file
139
lessons/50-js-variables.json
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../schemas/code-crispies-module-schema.json",
|
||||||
|
"id": "js-variables",
|
||||||
|
"title": "JS Variables",
|
||||||
|
"description": "Learn to declare variables with let and const, and work with basic data types in JavaScript.",
|
||||||
|
"mode": "javascript",
|
||||||
|
"difficulty": "beginner",
|
||||||
|
"lessons": [
|
||||||
|
{
|
||||||
|
"id": "js-const",
|
||||||
|
"title": "Constants",
|
||||||
|
"description": "Use <kbd>const</kbd> to declare a variable that cannot be reassigned. Constants are the default choice for most values in modern JavaScript.",
|
||||||
|
"task": "Declare a constant named <kbd>name</kbd> with the value <kbd>\"Alice\"</kbd>",
|
||||||
|
"previewHTML": "<p id=\"out\">Waiting...</p>",
|
||||||
|
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }",
|
||||||
|
"sandboxCSS": "",
|
||||||
|
"initialCode": "",
|
||||||
|
"codePrefix": "",
|
||||||
|
"codeSuffix": "\ndocument.getElementById('out').textContent = name;",
|
||||||
|
"solution": "const name = \"Alice\";",
|
||||||
|
"previewContainer": "preview-area",
|
||||||
|
"validations": [
|
||||||
|
{
|
||||||
|
"type": "contains",
|
||||||
|
"value": "const",
|
||||||
|
"message": "Use <kbd>const</kbd> to declare the variable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "regex",
|
||||||
|
"value": "const\\s+name\\s*=",
|
||||||
|
"message": "Declare a constant called <kbd>name</kbd>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "regex",
|
||||||
|
"value": "\"Alice\"|'Alice'|`Alice`",
|
||||||
|
"message": "Set the value to <kbd>\"Alice\"</kbd>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "js-let",
|
||||||
|
"title": "Let Variables",
|
||||||
|
"description": "Use <kbd>let</kbd> to declare variables that you plan to reassign later. Unlike <kbd>const</kbd>, a <kbd>let</kbd> variable can change its value.",
|
||||||
|
"task": "Declare a variable <kbd>count</kbd> with <kbd>let</kbd> set to <kbd>0</kbd>, then reassign it to <kbd>5</kbd>",
|
||||||
|
"previewHTML": "<p id=\"out\">Waiting...</p>",
|
||||||
|
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }",
|
||||||
|
"sandboxCSS": "",
|
||||||
|
"initialCode": "",
|
||||||
|
"codePrefix": "",
|
||||||
|
"codeSuffix": "\ndocument.getElementById('out').textContent = count;",
|
||||||
|
"solution": "let count = 0;\ncount = 5;",
|
||||||
|
"previewContainer": "preview-area",
|
||||||
|
"validations": [
|
||||||
|
{
|
||||||
|
"type": "regex",
|
||||||
|
"value": "let\\s+count\\s*=\\s*0",
|
||||||
|
"message": "Start with <kbd>let count = 0;</kbd>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "regex",
|
||||||
|
"value": "count\\s*=\\s*5",
|
||||||
|
"message": "Reassign count to <kbd>5</kbd>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "js-string",
|
||||||
|
"title": "Template Literals",
|
||||||
|
"description": "Template literals use backticks <kbd>`</kbd> and <kbd>${}</kbd> to embed expressions inside strings. This makes building dynamic text much easier than string concatenation.",
|
||||||
|
"task": "Create a constant <kbd>msg</kbd> using a template literal: <kbd>`Hello, ${name}!`</kbd>",
|
||||||
|
"previewHTML": "<p id=\"out\">Waiting...</p>",
|
||||||
|
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }",
|
||||||
|
"sandboxCSS": "",
|
||||||
|
"initialCode": "",
|
||||||
|
"codePrefix": "const name = \"World\";\n",
|
||||||
|
"codeSuffix": "\ndocument.getElementById('out').textContent = msg;",
|
||||||
|
"solution": "const msg = `Hello, ${name}!`;",
|
||||||
|
"previewContainer": "preview-area",
|
||||||
|
"validations": [
|
||||||
|
{
|
||||||
|
"type": "regex",
|
||||||
|
"value": "const\\s+msg\\s*=",
|
||||||
|
"message": "Declare a constant called <kbd>msg</kbd>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "contains",
|
||||||
|
"value": "${name}",
|
||||||
|
"message": "Use <kbd>${name}</kbd> inside backticks to embed the variable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "regex",
|
||||||
|
"value": "`.*\\$\\{name\\}.*`",
|
||||||
|
"message": "Wrap the whole string in backticks <kbd>`</kbd>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "js-array",
|
||||||
|
"title": "Arrays",
|
||||||
|
"description": "Arrays store ordered lists of values in square brackets. Access items by index (starting at 0) and use <kbd>.length</kbd> to get the count.",
|
||||||
|
"task": "Create a constant <kbd>colors</kbd> with an array: <kbd>[\"red\", \"green\", \"blue\"]</kbd>",
|
||||||
|
"previewHTML": "<p id=\"out\">Waiting...</p>",
|
||||||
|
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }",
|
||||||
|
"sandboxCSS": "",
|
||||||
|
"initialCode": "",
|
||||||
|
"codePrefix": "",
|
||||||
|
"codeSuffix": "\ndocument.getElementById('out').textContent = colors.join(', ');",
|
||||||
|
"solution": "const colors = [\"red\", \"green\", \"blue\"];",
|
||||||
|
"previewContainer": "preview-area",
|
||||||
|
"validations": [
|
||||||
|
{
|
||||||
|
"type": "regex",
|
||||||
|
"value": "const\\s+colors\\s*=",
|
||||||
|
"message": "Declare a constant called <kbd>colors</kbd>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "contains",
|
||||||
|
"value": "[",
|
||||||
|
"message": "Use square brackets <kbd>[</kbd> to create an array"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "regex",
|
||||||
|
"value": "(\"red\"|'red'|`red`)",
|
||||||
|
"message": "Include <kbd>\"red\"</kbd> in the array"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "regex",
|
||||||
|
"value": "(\"green\"|'green'|`green`)",
|
||||||
|
"message": "Include <kbd>\"green\"</kbd> in the array"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "regex",
|
||||||
|
"value": "(\"blue\"|'blue'|`blue`)",
|
||||||
|
"message": "Include <kbd>\"blue\"</kbd> in the array"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
139
lessons/51-js-dom.json
Normal file
139
lessons/51-js-dom.json
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../schemas/code-crispies-module-schema.json",
|
||||||
|
"id": "js-dom",
|
||||||
|
"title": "JS DOM",
|
||||||
|
"description": "Learn to select and modify HTML elements using JavaScript DOM methods like querySelector and textContent.",
|
||||||
|
"mode": "javascript",
|
||||||
|
"difficulty": "beginner",
|
||||||
|
"lessons": [
|
||||||
|
{
|
||||||
|
"id": "js-query",
|
||||||
|
"title": "querySelector",
|
||||||
|
"description": "Use <kbd>document.querySelector()</kbd> to find the first element matching a CSS selector. It returns a single element you can then modify.",
|
||||||
|
"task": "Select the <kbd>h1</kbd> element and store it in a constant called <kbd>title</kbd>",
|
||||||
|
"previewHTML": "<h1>Hello</h1><p id=\"out\">Waiting...</p>",
|
||||||
|
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }",
|
||||||
|
"sandboxCSS": "",
|
||||||
|
"initialCode": "",
|
||||||
|
"codePrefix": "",
|
||||||
|
"codeSuffix": "\ndocument.getElementById('out').textContent = title.tagName;",
|
||||||
|
"solution": "const title = document.querySelector('h1');",
|
||||||
|
"previewContainer": "preview-area",
|
||||||
|
"validations": [
|
||||||
|
{
|
||||||
|
"type": "contains",
|
||||||
|
"value": "querySelector",
|
||||||
|
"message": "Use <kbd>document.querySelector()</kbd> to select an element"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "regex",
|
||||||
|
"value": "querySelector\\(['\"`]h1['\"`]\\)",
|
||||||
|
"message": "Pass <kbd>'h1'</kbd> as the selector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "regex",
|
||||||
|
"value": "const\\s+title\\s*=",
|
||||||
|
"message": "Store the result in a constant called <kbd>title</kbd>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "js-text",
|
||||||
|
"title": "textContent",
|
||||||
|
"description": "The <kbd>textContent</kbd> property lets you read or change the text inside an element. Setting it replaces all existing text.",
|
||||||
|
"task": "Select the <kbd>.msg</kbd> element and set its <kbd>textContent</kbd> to <kbd>\"Done!\"</kbd>",
|
||||||
|
"previewHTML": "<p class=\"msg\">Waiting...</p>",
|
||||||
|
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }",
|
||||||
|
"sandboxCSS": "",
|
||||||
|
"initialCode": "",
|
||||||
|
"codePrefix": "",
|
||||||
|
"codeSuffix": "",
|
||||||
|
"solution": "document.querySelector('.msg').textContent = \"Done!\";",
|
||||||
|
"previewContainer": "preview-area",
|
||||||
|
"validations": [
|
||||||
|
{
|
||||||
|
"type": "contains",
|
||||||
|
"value": "querySelector",
|
||||||
|
"message": "Use <kbd>querySelector</kbd> to find the element"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "contains",
|
||||||
|
"value": "textContent",
|
||||||
|
"message": "Use the <kbd>textContent</kbd> property to change the text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "regex",
|
||||||
|
"value": "(\"Done!\"|'Done!'|`Done!`)",
|
||||||
|
"message": "Set the text to <kbd>\"Done!\"</kbd>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "js-style",
|
||||||
|
"title": "Inline Styles",
|
||||||
|
"description": "Access the <kbd>style</kbd> property to set inline CSS on an element. CSS properties with dashes become camelCase: <kbd>background-color</kbd> becomes <kbd>backgroundColor</kbd>.",
|
||||||
|
"task": "Select the <kbd>.box</kbd> element and set its <kbd>style.color</kbd> to <kbd>\"coral\"</kbd>",
|
||||||
|
"previewHTML": "<p class=\"box\">Style me!</p>",
|
||||||
|
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .box { font-size: 1.5rem; font-weight: bold; }",
|
||||||
|
"sandboxCSS": "",
|
||||||
|
"initialCode": "",
|
||||||
|
"codePrefix": "",
|
||||||
|
"codeSuffix": "",
|
||||||
|
"solution": "document.querySelector('.box').style.color = \"coral\";",
|
||||||
|
"previewContainer": "preview-area",
|
||||||
|
"validations": [
|
||||||
|
{
|
||||||
|
"type": "contains",
|
||||||
|
"value": "querySelector",
|
||||||
|
"message": "Use <kbd>querySelector</kbd> to find the element"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "contains",
|
||||||
|
"value": ".style.",
|
||||||
|
"message": "Use the <kbd>.style</kbd> property to set CSS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "regex",
|
||||||
|
"value": "style\\.color\\s*=",
|
||||||
|
"message": "Set <kbd>style.color</kbd> on the element"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "regex",
|
||||||
|
"value": "(\"coral\"|'coral'|`coral`)",
|
||||||
|
"message": "Set the color to <kbd>\"coral\"</kbd>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "js-classlist",
|
||||||
|
"title": "classList",
|
||||||
|
"description": "The <kbd>classList</kbd> property provides methods to add, remove, or toggle CSS classes on an element without touching other classes.",
|
||||||
|
"task": "Select the <kbd>.card</kbd> element and add the class <kbd>\"active\"</kbd> using <kbd>classList.add()</kbd>",
|
||||||
|
"previewHTML": "<div class=\"card\">Toggle me</div>",
|
||||||
|
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .card { padding: 1rem; border: 2px solid gray; border-radius: 8px; } .active { border-color: coral; background: #fff0ee; }",
|
||||||
|
"sandboxCSS": "",
|
||||||
|
"initialCode": "",
|
||||||
|
"codePrefix": "",
|
||||||
|
"codeSuffix": "",
|
||||||
|
"solution": "document.querySelector('.card').classList.add(\"active\");",
|
||||||
|
"previewContainer": "preview-area",
|
||||||
|
"validations": [
|
||||||
|
{
|
||||||
|
"type": "contains",
|
||||||
|
"value": "classList",
|
||||||
|
"message": "Use the <kbd>classList</kbd> property"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "regex",
|
||||||
|
"value": "classList\\.add\\(",
|
||||||
|
"message": "Call <kbd>classList.add()</kbd> to add a class"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "regex",
|
||||||
|
"value": "(\"active\"|'active'|`active`)",
|
||||||
|
"message": "Add the class <kbd>\"active\"</kbd>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
118
lessons/52-js-events.json
Normal file
118
lessons/52-js-events.json
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../schemas/code-crispies-module-schema.json",
|
||||||
|
"id": "js-events",
|
||||||
|
"title": "JS Events",
|
||||||
|
"description": "Learn to respond to user interactions with addEventListener for clicks, input changes, and keyboard events.",
|
||||||
|
"mode": "javascript",
|
||||||
|
"difficulty": "beginner",
|
||||||
|
"lessons": [
|
||||||
|
{
|
||||||
|
"id": "js-click",
|
||||||
|
"title": "Click Events",
|
||||||
|
"description": "Use <kbd>addEventListener('click', ...)</kbd> to run code when a user clicks an element. The first argument is the event name, the second is a callback function.",
|
||||||
|
"task": "Add a click listener to the <kbd>.btn</kbd> element that sets the <kbd>.msg</kbd> text to <kbd>\"Clicked!\"</kbd>",
|
||||||
|
"previewHTML": "<button class=\"btn\">Click me</button><p class=\"msg\">Waiting...</p>",
|
||||||
|
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .btn { padding: 0.5rem 1rem; border: none; background: steelblue; color: white; border-radius: 4px; cursor: pointer; }",
|
||||||
|
"sandboxCSS": "",
|
||||||
|
"initialCode": "",
|
||||||
|
"codePrefix": "const btn = document.querySelector('.btn');\nconst msg = document.querySelector('.msg');\n\n",
|
||||||
|
"codeSuffix": "",
|
||||||
|
"solution": "btn.addEventListener('click', () => {\n msg.textContent = \"Clicked!\";\n});",
|
||||||
|
"previewContainer": "preview-area",
|
||||||
|
"validations": [
|
||||||
|
{
|
||||||
|
"type": "contains",
|
||||||
|
"value": "addEventListener",
|
||||||
|
"message": "Use <kbd>addEventListener</kbd> to listen for events"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "regex",
|
||||||
|
"value": "addEventListener\\(['\"`]click['\"`]",
|
||||||
|
"message": "Listen for the <kbd>'click'</kbd> event"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "contains",
|
||||||
|
"value": "textContent",
|
||||||
|
"message": "Use <kbd>textContent</kbd> to update the text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "regex",
|
||||||
|
"value": "(\"Clicked!\"|'Clicked!'|`Clicked!`)",
|
||||||
|
"message": "Set the text to <kbd>\"Clicked!\"</kbd>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "js-toggle",
|
||||||
|
"title": "Toggle Classes",
|
||||||
|
"description": "Combine events with <kbd>classList.toggle()</kbd> to switch a class on and off. Each click adds the class if missing, or removes it if present.",
|
||||||
|
"task": "Add a click listener to <kbd>.btn</kbd> that toggles the class <kbd>\"on\"</kbd> on <kbd>.lamp</kbd>",
|
||||||
|
"previewHTML": "<button class=\"btn\">Toggle</button><div class=\"lamp\">💡</div>",
|
||||||
|
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; text-align: center; } .btn { padding: 0.5rem 1rem; border: none; background: steelblue; color: white; border-radius: 4px; cursor: pointer; } .lamp { font-size: 3rem; margin-top: 1rem; opacity: 0.3; transition: opacity 0.3s; } .lamp.on { opacity: 1; }",
|
||||||
|
"sandboxCSS": "",
|
||||||
|
"initialCode": "",
|
||||||
|
"codePrefix": "const btn = document.querySelector('.btn');\nconst lamp = document.querySelector('.lamp');\n\n",
|
||||||
|
"codeSuffix": "",
|
||||||
|
"solution": "btn.addEventListener('click', () => {\n lamp.classList.toggle('on');\n});",
|
||||||
|
"previewContainer": "preview-area",
|
||||||
|
"validations": [
|
||||||
|
{
|
||||||
|
"type": "contains",
|
||||||
|
"value": "addEventListener",
|
||||||
|
"message": "Use <kbd>addEventListener</kbd> to listen for events"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "regex",
|
||||||
|
"value": "addEventListener\\(['\"`]click['\"`]",
|
||||||
|
"message": "Listen for the <kbd>'click'</kbd> event"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "regex",
|
||||||
|
"value": "classList\\.toggle\\(",
|
||||||
|
"message": "Use <kbd>classList.toggle()</kbd> to switch the class"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "regex",
|
||||||
|
"value": "(\"on\"|'on'|`on`)",
|
||||||
|
"message": "Toggle the class <kbd>\"on\"</kbd>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "js-input",
|
||||||
|
"title": "Input Events",
|
||||||
|
"description": "The <kbd>input</kbd> event fires every time the value of an input field changes. Use <kbd>event.target.value</kbd> to read the current value.",
|
||||||
|
"task": "Add an input listener to <kbd>.field</kbd> that sets <kbd>.out</kbd> text to the input's value",
|
||||||
|
"previewHTML": "<input class=\"field\" placeholder=\"Type here...\"><p class=\"out\">Echo: </p>",
|
||||||
|
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .field { padding: 0.5rem; border: 2px solid #ccc; border-radius: 4px; font-size: 1rem; width: 100%; box-sizing: border-box; }",
|
||||||
|
"sandboxCSS": "",
|
||||||
|
"initialCode": "",
|
||||||
|
"codePrefix": "const field = document.querySelector('.field');\nconst out = document.querySelector('.out');\n\n",
|
||||||
|
"codeSuffix": "",
|
||||||
|
"solution": "field.addEventListener('input', (event) => {\n out.textContent = event.target.value;\n});",
|
||||||
|
"previewContainer": "preview-area",
|
||||||
|
"validations": [
|
||||||
|
{
|
||||||
|
"type": "contains",
|
||||||
|
"value": "addEventListener",
|
||||||
|
"message": "Use <kbd>addEventListener</kbd> to listen for events"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "regex",
|
||||||
|
"value": "addEventListener\\(['\"`]input['\"`]",
|
||||||
|
"message": "Listen for the <kbd>'input'</kbd> event"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "contains",
|
||||||
|
"value": "textContent",
|
||||||
|
"message": "Use <kbd>textContent</kbd> to update the output"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "regex",
|
||||||
|
"value": "(event|e|evt)\\.target\\.value",
|
||||||
|
"message": "Read the input value with <kbd>event.target.value</kbd>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
"id": "flexbox-1",
|
"id": "flexbox-1",
|
||||||
"title": "Container",
|
"title": "Container",
|
||||||
"description": "Before flexbox, creating even simple layouts required floats, positioning hacks, or table-based layouts. Flexbox (Flexible Box Layout) revolutionized CSS by providing a one-dimensional layout system designed specifically for distributing space and aligning content.<br><br><strong>How it works:</strong> When you set <kbd>display: flex</kbd> on an element, it becomes a <em>flex container</em>. Its direct children automatically become <em>flex items</em> that flow along a main axis (horizontal by default). This single property transforms stacked block elements into a horizontal row.<br><br><strong>The two axes:</strong><br>• <em>Main axis</em> – The primary direction items flow (row = left→right)<br>• <em>Cross axis</em> – Perpendicular to main (row = top→bottom)<br><br><pre>.nav {\n display: flex;\n}</pre>",
|
"description": "Before flexbox, creating even simple layouts required floats, positioning hacks, or table-based layouts. Flexbox (Flexible Box Layout) revolutionized CSS by providing a one-dimensional layout system designed specifically for distributing space and aligning content.<br><br><strong>How it works:</strong> When you set <kbd>display: flex</kbd> on an element, it becomes a <em>flex container</em>. Its direct children automatically become <em>flex items</em> that flow along a main axis (horizontal by default). This single property transforms stacked block elements into a horizontal row.<br><br><strong>The two axes:</strong><br>• <em>Main axis</em> – The primary direction items flow (row = left→right)<br>• <em>Cross axis</em> – Perpendicular to main (row = top→bottom)<br><br><pre>.nav {\n display: flex;\n}</pre>",
|
||||||
"task": "This navigation menu stacks vertically. Add <kbd>display: flex</kbd> to <kbd>.nav</kbd> to arrange the links horizontally.",
|
"task": "The navigation links are stacking vertically. Make them display side by side in a horizontal row.",
|
||||||
"previewHTML": "<nav class=\"nav\"><a href=\"#\">Home</a><a href=\"#\">Products</a><a href=\"#\">About</a><a href=\"#\">Contact</a></nav>",
|
"previewHTML": "<nav class=\"nav\"><a href=\"#\">Home</a><a href=\"#\">Products</a><a href=\"#\">About</a><a href=\"#\">Contact</a></nav>",
|
||||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; margin: 0; } .nav { background: #1a1a2e; padding: 1rem; } .nav a { color: white; text-decoration: none; padding: 8px 1rem; border-radius: 4px; } .nav a:hover { background: rgba(255,255,255,0.1); }",
|
"previewBaseCSS": "body { font-family: system-ui, sans-serif; margin: 0; } .nav { background: #1a1a2e; padding: 1rem; } .nav a { color: white; text-decoration: none; padding: 8px 1rem; border-radius: 4px; } .nav a:hover { background: rgba(255,255,255,0.1); }",
|
||||||
"sandboxCSS": "",
|
"sandboxCSS": "",
|
||||||
@@ -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": "Try changing the display mode to create a flex container"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"id": "flexbox-2",
|
"id": "flexbox-2",
|
||||||
"title": "Gap",
|
"title": "Gap",
|
||||||
"description": "The <kbd>gap</kbd> property adds consistent spacing between flex items without needing margins. It only creates space between items, not around the edges.",
|
"description": "The <kbd>gap</kbd> property adds consistent spacing between flex items without needing margins. It only creates space between items, not around the edges.",
|
||||||
"task": "Add <kbd>gap: 1rem</kbd> to space out the navigation links evenly.",
|
"task": "The navigation links are crammed together with no breathing room. Add 1rem of spacing between them.",
|
||||||
"previewHTML": "<nav class=\"nav\"><a href=\"#\">Home</a><a href=\"#\">Products</a><a href=\"#\">About</a><a href=\"#\">Contact</a></nav>",
|
"previewHTML": "<nav class=\"nav\"><a href=\"#\">Home</a><a href=\"#\">Products</a><a href=\"#\">About</a><a href=\"#\">Contact</a></nav>",
|
||||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; margin: 0; } .nav { background: #1a1a2e; padding: 1rem; display: flex; } .nav a { color: white; text-decoration: none; padding: 8px 1rem; border-radius: 4px; background: rgba(255,255,255,0.1); }",
|
"previewBaseCSS": "body { font-family: system-ui, sans-serif; margin: 0; } .nav { background: #1a1a2e; padding: 1rem; display: flex; } .nav a { color: white; text-decoration: none; padding: 8px 1rem; border-radius: 4px; background: rgba(255,255,255,0.1); }",
|
||||||
"sandboxCSS": "",
|
"sandboxCSS": "",
|
||||||
@@ -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": "Use the property that adds spacing between flex items"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
"id": "flexbox-3",
|
"id": "flexbox-3",
|
||||||
"title": "Justify Content",
|
"title": "Justify Content",
|
||||||
"description": "<kbd>justify-content</kbd> distributes items along the main axis. Common values:<br>• <kbd>flex-start</kbd> – pack items at the start<br>• <kbd>flex-end</kbd> – pack at the end<br>• <kbd>center</kbd> – center items<br>• <kbd>space-between</kbd> – equal space between items<br>• <kbd>space-around</kbd> – equal space around items",
|
"description": "<kbd>justify-content</kbd> distributes items along the main axis. Common values:<br>• <kbd>flex-start</kbd> – pack items at the start<br>• <kbd>flex-end</kbd> – pack at the end<br>• <kbd>center</kbd> – center items<br>• <kbd>space-between</kbd> – equal space between items<br>• <kbd>space-around</kbd> – equal space around items",
|
||||||
"task": "Push the \"Login\" button to the right by setting <kbd>justify-content: space-between</kbd> on the nav.",
|
"task": "The Login button should sit on the far right, with the other links staying on the left. Distribute the space between them.",
|
||||||
"previewHTML": "<nav class=\"nav\"><div class=\"links\"><a href=\"#\">Home</a><a href=\"#\">Products</a><a href=\"#\">About</a></div><a href=\"#\" class=\"login\">Login</a></nav>",
|
"previewHTML": "<nav class=\"nav\"><div class=\"links\"><a href=\"#\">Home</a><a href=\"#\">Products</a><a href=\"#\">About</a></div><a href=\"#\" class=\"login\">Login</a></nav>",
|
||||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; margin: 0; } .nav { background: #1a1a2e; padding: 1rem; display: flex; } .links { display: flex; gap: 8px; } .nav a { color: white; text-decoration: none; padding: 8px 1rem; border-radius: 4px; } .nav a:hover { background: rgba(255,255,255,0.1); } .login { background: steelblue; }",
|
"previewBaseCSS": "body { font-family: system-ui, sans-serif; margin: 0; } .nav { background: #1a1a2e; padding: 1rem; display: flex; } .links { display: flex; gap: 8px; } .nav a { color: white; text-decoration: none; padding: 8px 1rem; border-radius: 4px; } .nav a:hover { background: rgba(255,255,255,0.1); } .login { background: steelblue; }",
|
||||||
"sandboxCSS": "",
|
"sandboxCSS": "",
|
||||||
@@ -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": "Use the property that distributes items along the main axis"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
"id": "flexbox-4",
|
"id": "flexbox-4",
|
||||||
"title": "Align Items",
|
"title": "Align Items",
|
||||||
"description": "<kbd>align-items</kbd> controls alignment on the cross axis (vertical when flex-direction is row). Values include:<br>• <kbd>stretch</kbd> – stretch to fill (default)<br>• <kbd>flex-start</kbd> – align to top<br>• <kbd>flex-end</kbd> – align to bottom<br>• <kbd>center</kbd> – center vertically",
|
"description": "<kbd>align-items</kbd> controls alignment on the cross axis (vertical when flex-direction is row). Values include:<br>• <kbd>stretch</kbd> – stretch to fill (default)<br>• <kbd>flex-start</kbd> – align to top<br>• <kbd>flex-end</kbd> – align to bottom<br>• <kbd>center</kbd> – center vertically",
|
||||||
"task": "The logo and nav links have different heights. Center them vertically with <kbd>align-items: center</kbd>.",
|
"task": "The logo and nav links sit at different heights. Center them vertically so they line up.",
|
||||||
"previewHTML": "<header class=\"header\"><div class=\"logo\">ACME</div><nav><a href=\"#\">Products</a><a href=\"#\">Pricing</a><a href=\"#\">Docs</a></nav></header>",
|
"previewHTML": "<header class=\"header\"><div class=\"logo\">ACME</div><nav><a href=\"#\">Products</a><a href=\"#\">Pricing</a><a href=\"#\">Docs</a></nav></header>",
|
||||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; margin: 0; } .header { background: white; padding: 1rem 2rem; display: flex; justify-content: space-between; border-bottom: 1px solid #eee; } .logo { font-size: 1.5rem; font-weight: bold; color: steelblue; } nav { display: flex; gap: 1rem; } nav a { color: #333; text-decoration: none; font-size: 0.9rem; }",
|
"previewBaseCSS": "body { font-family: system-ui, sans-serif; margin: 0; } .header { background: white; padding: 1rem 2rem; display: flex; justify-content: space-between; border-bottom: 1px solid #eee; } .logo { font-size: 1.5rem; font-weight: bold; color: steelblue; } nav { display: flex; gap: 1rem; } nav a { color: #333; text-decoration: none; font-size: 0.9rem; }",
|
||||||
"sandboxCSS": "",
|
"sandboxCSS": "",
|
||||||
@@ -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": "Use the property that controls cross-axis alignment"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
"id": "flexbox-5",
|
"id": "flexbox-5",
|
||||||
"title": "Flex Wrap",
|
"title": "Flex Wrap",
|
||||||
"description": "By default, flex items squeeze onto one line. <kbd>flex-wrap: wrap</kbd> allows items to flow onto multiple lines when they run out of space.",
|
"description": "By default, flex items squeeze onto one line. <kbd>flex-wrap: wrap</kbd> allows items to flow onto multiple lines when they run out of space.",
|
||||||
"task": "These cards overflow the container. Add <kbd>flex-wrap: wrap</kbd> to allow them to wrap to new rows.",
|
"task": "The cards overflow the container instead of fitting within it. Allow the items to flow onto new rows when they run out of space.",
|
||||||
"previewHTML": "<div class=\"cards\"><article class=\"card\">Card 1</article><article class=\"card\">Card 2</article><article class=\"card\">Card 3</article><article class=\"card\">Card 4</article><article class=\"card\">Card 5</article><article class=\"card\">Card 6</article></div>",
|
"previewHTML": "<div class=\"cards\"><article class=\"card\">Card 1</article><article class=\"card\">Card 2</article><article class=\"card\">Card 3</article><article class=\"card\">Card 4</article><article class=\"card\">Card 5</article><article class=\"card\">Card 6</article></div>",
|
||||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .cards { display: flex; gap: 1rem; } .card { background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); min-width: 120px; text-align: center; }",
|
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .cards { display: flex; gap: 1rem; } .card { background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); min-width: 120px; text-align: center; }",
|
||||||
"sandboxCSS": "",
|
"sandboxCSS": "",
|
||||||
@@ -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": "Use the property that allows flex items to wrap onto new lines"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
"id": "flexbox-6",
|
"id": "flexbox-6",
|
||||||
"title": "Flex Grow",
|
"title": "Flex Grow",
|
||||||
"description": "The <kbd>flex</kbd> property on items controls how they grow and shrink. <kbd>flex: 1</kbd> makes an item grow to fill available space. Multiple items with <kbd>flex: 1</kbd> share space equally.",
|
"description": "The <kbd>flex</kbd> property on items controls how they grow and shrink. <kbd>flex: 1</kbd> makes an item grow to fill available space. Multiple items with <kbd>flex: 1</kbd> share space equally.",
|
||||||
"task": "Make the search input expand to fill available space by setting <kbd>flex: 1</kbd> on <kbd>.search</kbd>.",
|
"task": "The search input is too narrow. Make it stretch to fill all the remaining space in the toolbar.",
|
||||||
"previewHTML": "<div class=\"toolbar\"><input class=\"search\" type=\"text\" placeholder=\"Search...\"><button class=\"btn\">Search</button><button class=\"btn\">Filters</button></div>",
|
"previewHTML": "<div class=\"toolbar\"><input class=\"search\" type=\"text\" placeholder=\"Search...\"><button class=\"btn\">Search</button><button class=\"btn\">Filters</button></div>",
|
||||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .toolbar { display: flex; gap: 8px; padding: 1rem; background: #f5f5f5; border-radius: 8px; } .search { padding: 8px 1rem; border: 1px solid #ddd; border-radius: 4px; font-size: 1rem; } .btn { padding: 8px 1rem; background: steelblue; color: white; border: none; border-radius: 4px; cursor: pointer; }",
|
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .toolbar { display: flex; gap: 8px; padding: 1rem; background: #f5f5f5; border-radius: 8px; } .search { padding: 8px 1rem; border: 1px solid #ddd; border-radius: 4px; font-size: 1rem; } .btn { padding: 8px 1rem; background: steelblue; color: white; border: none; border-radius: 4px; cursor: pointer; }",
|
||||||
"sandboxCSS": "",
|
"sandboxCSS": "",
|
||||||
@@ -125,9 +125,9 @@
|
|||||||
"previewContainer": "preview-area",
|
"previewContainer": "preview-area",
|
||||||
"validations": [
|
"validations": [
|
||||||
{
|
{
|
||||||
"type": "property_value",
|
"type": "regex",
|
||||||
"value": { "property": "flex", "expected": "1" },
|
"value": "(flex\\s*:\\s*1|flex-grow\\s*:\\s*1)",
|
||||||
"message": "Set <kbd>flex: 1</kbd>"
|
"message": "Use the property that makes a flex item grow to fill available space"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
65
package-lock.json
generated
65
package-lock.json
generated
@@ -13,12 +13,15 @@
|
|||||||
"@codemirror/commands": "^6.10.1",
|
"@codemirror/commands": "^6.10.1",
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-html": "^6.4.11",
|
"@codemirror/lang-html": "^6.4.11",
|
||||||
|
"@codemirror/lang-javascript": "^6.2.5",
|
||||||
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
"@codemirror/state": "^6.5.2",
|
"@codemirror/state": "^6.5.2",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.39.4",
|
"@codemirror/view": "^6.39.4",
|
||||||
"@emmetio/codemirror6-plugin": "^0.4.0",
|
"@emmetio/codemirror6-plugin": "^0.4.0",
|
||||||
"@supabase/supabase-js": "^2.90.1",
|
"@supabase/supabase-js": "^2.90.1",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
|
"marked": "^17.0.1",
|
||||||
"whatwg-fetch": "^3.6.20"
|
"whatwg-fetch": "^3.6.20"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -156,7 +159,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz",
|
||||||
"integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==",
|
"integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "^6.0.0",
|
"@codemirror/language": "^6.0.0",
|
||||||
"@codemirror/state": "^6.0.0",
|
"@codemirror/state": "^6.0.0",
|
||||||
@@ -169,7 +171,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.1.tgz",
|
||||||
"integrity": "sha512-uWDWFypNdQmz2y1LaNJzK7fL7TYKLeUAU0npEC685OKTF3KcQ2Vu3klIM78D7I6wGhktme0lh3CuQLv0ZCrD9Q==",
|
"integrity": "sha512-uWDWFypNdQmz2y1LaNJzK7fL7TYKLeUAU0npEC685OKTF3KcQ2Vu3klIM78D7I6wGhktme0lh3CuQLv0ZCrD9Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "^6.0.0",
|
"@codemirror/language": "^6.0.0",
|
||||||
"@codemirror/state": "^6.4.0",
|
"@codemirror/state": "^6.4.0",
|
||||||
@@ -182,7 +183,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
|
||||||
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
|
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.0.0",
|
"@codemirror/autocomplete": "^6.0.0",
|
||||||
"@codemirror/language": "^6.0.0",
|
"@codemirror/language": "^6.0.0",
|
||||||
@@ -196,7 +196,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
|
||||||
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
|
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.0.0",
|
"@codemirror/autocomplete": "^6.0.0",
|
||||||
"@codemirror/lang-css": "^6.0.0",
|
"@codemirror/lang-css": "^6.0.0",
|
||||||
@@ -210,9 +209,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@codemirror/lang-javascript": {
|
"node_modules/@codemirror/lang-javascript": {
|
||||||
"version": "6.2.4",
|
"version": "6.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
|
||||||
"integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==",
|
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.0.0",
|
"@codemirror/autocomplete": "^6.0.0",
|
||||||
@@ -224,12 +223,26 @@
|
|||||||
"@lezer/javascript": "^1.0.0"
|
"@lezer/javascript": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@codemirror/lang-markdown": {
|
||||||
|
"version": "6.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz",
|
||||||
|
"integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.7.1",
|
||||||
|
"@codemirror/lang-html": "^6.0.0",
|
||||||
|
"@codemirror/language": "^6.3.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.0.0",
|
||||||
|
"@lezer/common": "^1.2.1",
|
||||||
|
"@lezer/markdown": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@codemirror/language": {
|
"node_modules/@codemirror/language": {
|
||||||
"version": "6.11.3",
|
"version": "6.11.3",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz",
|
||||||
"integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==",
|
"integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/state": "^6.0.0",
|
"@codemirror/state": "^6.0.0",
|
||||||
"@codemirror/view": "^6.23.0",
|
"@codemirror/view": "^6.23.0",
|
||||||
@@ -266,7 +279,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
|
||||||
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
|
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@marijn/find-cluster-break": "^1.0.0"
|
"@marijn/find-cluster-break": "^1.0.0"
|
||||||
}
|
}
|
||||||
@@ -288,7 +300,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.4.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.4.tgz",
|
||||||
"integrity": "sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA==",
|
"integrity": "sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/state": "^6.5.0",
|
"@codemirror/state": "^6.5.0",
|
||||||
"crelt": "^1.0.6",
|
"crelt": "^1.0.6",
|
||||||
@@ -384,7 +395,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@@ -408,7 +418,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -974,9 +983,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@lezer/common": {
|
"node_modules/@lezer/common": {
|
||||||
"version": "1.4.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.0.tgz",
|
||||||
"integrity": "sha512-DVeMRoGrgn/k45oQNu189BoW4SZwgZFzJ1+1TV5j2NJ/KFC83oa/enRqZSGshyeMk5cPWMhsKs9nx+8o0unwGg==",
|
"integrity": "sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@lezer/css": {
|
"node_modules/@lezer/css": {
|
||||||
@@ -1030,6 +1039,16 @@
|
|||||||
"@lezer/common": "^1.0.0"
|
"@lezer/common": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@lezer/markdown": {
|
||||||
|
"version": "1.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.3.tgz",
|
||||||
|
"integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.5.0",
|
||||||
|
"@lezer/highlight": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@marijn/find-cluster-break": {
|
"node_modules/@marijn/find-cluster-break": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||||
@@ -2336,7 +2355,6 @@
|
|||||||
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cssstyle": "^4.2.1",
|
"cssstyle": "^4.2.1",
|
||||||
"data-urls": "^5.0.0",
|
"data-urls": "^5.0.0",
|
||||||
@@ -2433,6 +2451,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/marked": {
|
||||||
|
"version": "17.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz",
|
||||||
|
"integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"marked": "bin/marked.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/min-indent": {
|
"node_modules/min-indent": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||||
@@ -2579,7 +2609,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -3123,7 +3152,6 @@
|
|||||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
@@ -3222,7 +3250,6 @@
|
|||||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/chai": "^5.2.2",
|
"@types/chai": "^5.2.2",
|
||||||
"@vitest/expect": "3.2.4",
|
"@vitest/expect": "3.2.4",
|
||||||
|
|||||||
@@ -37,12 +37,15 @@
|
|||||||
"@codemirror/commands": "^6.10.1",
|
"@codemirror/commands": "^6.10.1",
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-html": "^6.4.11",
|
"@codemirror/lang-html": "^6.4.11",
|
||||||
|
"@codemirror/lang-javascript": "^6.2.5",
|
||||||
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
"@codemirror/state": "^6.5.2",
|
"@codemirror/state": "^6.5.2",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.39.4",
|
"@codemirror/view": "^6.39.4",
|
||||||
"@emmetio/codemirror6-plugin": "^0.4.0",
|
"@emmetio/codemirror6-plugin": "^0.4.0",
|
||||||
"@supabase/supabase-js": "^2.90.1",
|
"@supabase/supabase-js": "^2.90.1",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
|
"marked": "^17.0.1",
|
||||||
"whatwg-fetch": "^3.6.20"
|
"whatwg-fetch": "^3.6.20"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,8 @@
|
|||||||
},
|
},
|
||||||
"mode": {
|
"mode": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["css", "tailwind", "html"],
|
"enum": ["css", "tailwind", "html", "markdown", "javascript"],
|
||||||
"description": "Whether this module teaches CSS, Tailwind, or HTML"
|
"description": "Whether this module teaches CSS, Tailwind, HTML, Markdown, or JavaScript"
|
||||||
},
|
},
|
||||||
"difficulty": {
|
"difficulty": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
},
|
},
|
||||||
"mode": {
|
"mode": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["css", "tailwind", "html"],
|
"enum": ["css", "tailwind", "html", "markdown", "javascript"],
|
||||||
"description": "Override module mode for individual lessons"
|
"description": "Override module mode for individual lessons"
|
||||||
},
|
},
|
||||||
"tailwindConfig": {
|
"tailwindConfig": {
|
||||||
|
|||||||
76
specs/003-flexbox-task-wording/plan.md
Normal file
76
specs/003-flexbox-task-wording/plan.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Implementation Plan
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Rewrite all 6 flexbox lesson task descriptions to describe the desired visual outcome instead of giving the exact CSS declaration. Update validation messages to hint without revealing answers, and accept alternative valid solutions where applicable.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
This is a content-only change to a single JSON file (`lessons/flexbox.json`). Each lesson needs three edits:
|
||||||
|
|
||||||
|
1. **Task text**: Replace copy-pasteable CSS declarations with outcome-oriented descriptions
|
||||||
|
2. **Validation messages**: Replace answer-revealing messages with pedagogical hints
|
||||||
|
3. **Validations array**: Add alternative accepted solutions where multiple CSS approaches achieve the same visual result
|
||||||
|
|
||||||
|
The lesson `description` fields (which teach concepts with code examples) remain unchanged — they are the learning material, not the exercise prompt.
|
||||||
|
|
||||||
|
## File Mapping
|
||||||
|
|
||||||
|
| File | Action | Description |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| `lessons/flexbox.json` | modify | Rewrite `task` and validation `message` fields for all 6 lessons; add alternative validations for flexbox-6 |
|
||||||
|
|
||||||
|
No new files need to be created. No validator code changes needed — the existing `property_value` and `regex` validation types already support everything required.
|
||||||
|
|
||||||
|
## Detailed Changes Per Lesson
|
||||||
|
|
||||||
|
### flexbox-1 (Container)
|
||||||
|
- **Task**: Describe that nav links stack vertically and should display side by side
|
||||||
|
- **Validation msg**: Hint at display property for flex layout
|
||||||
|
- **Alt solutions**: None — `display: flex` is the only correct answer (inline-flex changes block behavior)
|
||||||
|
|
||||||
|
### flexbox-2 (Gap)
|
||||||
|
- **Task**: Describe that links are crammed together and need 1rem of spacing between them
|
||||||
|
- **Validation msg**: Hint at the gap property
|
||||||
|
- **Alt solutions**: None — `gap: 1rem` is the specific expected value
|
||||||
|
|
||||||
|
### flexbox-3 (Justify Content)
|
||||||
|
- **Task**: Describe that Login button should be pushed to the far right, with nav links on the left
|
||||||
|
- **Validation msg**: Hint at main-axis distribution property
|
||||||
|
- **Alt solutions**: None — `justify-content: space-between` is the only property that works when targeting `.nav`
|
||||||
|
|
||||||
|
### flexbox-4 (Align Items)
|
||||||
|
- **Task**: Describe the visual misalignment and ask for vertical centering
|
||||||
|
- **Validation msg**: Hint at cross-axis alignment property
|
||||||
|
- **Alt solutions**: None — `align-items: center` is the correct answer
|
||||||
|
|
||||||
|
### flexbox-5 (Flex Wrap)
|
||||||
|
- **Task**: Describe cards overflowing and needing to flow onto new rows
|
||||||
|
- **Validation msg**: Hint at wrapping property
|
||||||
|
- **Alt solutions**: None — `flex-wrap: wrap` is the only answer
|
||||||
|
|
||||||
|
### flexbox-6 (Flex Grow)
|
||||||
|
- **Task**: Describe that the search input should stretch to fill remaining space
|
||||||
|
- **Validation msg**: Hint at flex growth property
|
||||||
|
- **Alt solutions**: Accept both `flex: 1` and `flex-grow: 1` via regex validation
|
||||||
|
|
||||||
|
## Architecture Decisions
|
||||||
|
|
||||||
|
1. **No validator code changes**: The existing `regex` validation type can handle alternative solutions for flexbox-6. No need to add a new validation type.
|
||||||
|
2. **Keep values in tasks where needed**: Some tasks mention target values like "1rem" since the validator checks exact values and students need to know the amount. The key change is removing the *property name* from the task.
|
||||||
|
3. **Solution field unchanged**: The `solution` field is used for the "show solution" feature and should remain as the canonical answer.
|
||||||
|
4. **codePrefix unchanged**: The existing codePrefix already shows the selector context (e.g., `.nav {`), which is enough guidance for students.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
| Risk | Likelihood | Mitigation |
|
||||||
|
|------|-----------|------------|
|
||||||
|
| Tasks become too vague for beginners | Low | Descriptions still teach the property; tasks describe specific visual outcomes |
|
||||||
|
| Alternative regex validation too permissive | Low | Regex will be specific to `flex:\s*1` and `flex-grow:\s*1` patterns |
|
||||||
|
| Validation messages too cryptic | Low | Messages will hint at the property category without giving the exact declaration |
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
1. **Run existing test suite**: `npm run test` — all tests should pass since no code or module structure changes
|
||||||
|
2. **Manual verification**: Validate that each rewritten task accurately describes the visual outcome shown in the preview
|
||||||
|
3. **JSON schema validation**: Ensure `lessons/flexbox.json` still conforms to the module schema
|
||||||
35
specs/003-flexbox-task-wording/spec.md
Normal file
35
specs/003-flexbox-task-wording/spec.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# fix: remove answers from flexbox task descriptions (copy-paste score 95%)
|
||||||
|
|
||||||
|
**Issue**: [libretech/code-crispies#3](https://git.librete.ch/libretech/code-crispies/issues/3)
|
||||||
|
**State**: open
|
||||||
|
**Author**: libretech
|
||||||
|
**Labels**: none
|
||||||
|
**Complexity**: simple
|
||||||
|
|
||||||
|
## Issue Body
|
||||||
|
|
||||||
|
Pedagogy audit: All 6 flexbox exercises give the exact CSS declaration in the task text. Students type without understanding. Rewrite tasks to describe the DESIRED OUTCOME instead of the exact code. Example: 'Add display: flex' → 'The navigation links stack vertically. Make them display side by side.' Accept multiple valid solutions in validations.
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
All 6 lessons in `lessons/flexbox.json` have task descriptions that include the exact CSS declaration students need to type:
|
||||||
|
|
||||||
|
| Lesson | Current Task (gives away answer) |
|
||||||
|
|--------|----------------------------------|
|
||||||
|
| flexbox-1 | "Add `display: flex` to `.nav`" |
|
||||||
|
| flexbox-2 | "Add `gap: 1rem` to space out..." |
|
||||||
|
| flexbox-3 | "setting `justify-content: space-between` on the nav" |
|
||||||
|
| flexbox-4 | "Center them vertically with `align-items: center`" |
|
||||||
|
| flexbox-5 | "Add `flex-wrap: wrap` to allow them to wrap" |
|
||||||
|
| flexbox-6 | "setting `flex: 1` on `.search`" |
|
||||||
|
|
||||||
|
Validation error messages also give away answers (e.g., "Set `display: flex`").
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
1. All 6 flexbox task descriptions rewritten to describe the desired visual outcome, not the exact CSS code
|
||||||
|
2. Students cannot copy-paste from the task into the editor to pass
|
||||||
|
3. Validation error messages updated to provide hints without revealing the exact declaration
|
||||||
|
4. Where applicable, validations accept multiple valid CSS solutions (e.g., `flex: 1` and `flex-grow: 1`)
|
||||||
|
5. Existing tests continue to pass
|
||||||
|
6. Lesson descriptions (which teach the concepts) remain unchanged
|
||||||
13
specs/003-flexbox-task-wording/tasks.md
Normal file
13
specs/003-flexbox-task-wording/tasks.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
## Phase 1: Core Content Changes
|
||||||
|
- [X] Task 1.1: Rewrite task text for all 6 flexbox lessons to describe visual outcomes [P]
|
||||||
|
- [X] Task 1.2: Rewrite validation error messages to hint without revealing answers [P]
|
||||||
|
|
||||||
|
## Phase 2: Alternative Validations
|
||||||
|
- [X] Task 2.1: Add regex validation for flexbox-6 to accept both `flex: 1` and `flex-grow: 1`
|
||||||
|
|
||||||
|
## Phase 3: Validation
|
||||||
|
- [X] Task 3.1: Run existing test suite to confirm no regressions
|
||||||
|
- [X] Task 3.2: Verify flexbox.json still conforms to module schema
|
||||||
|
- [X] Task 3.3: Run lesson format check (`npm run format.lessons`)
|
||||||
312
src/app.js
312
src/app.js
@@ -1,6 +1,6 @@
|
|||||||
import { LessonEngine } from "./impl/LessonEngine.js";
|
import { LessonEngine } from "./impl/LessonEngine.js";
|
||||||
import { CodeEditor, crispyEditorTheme } from "./impl/CodeEditor.js";
|
import { CodeEditor, crispyEditorTheme } from "./impl/CodeEditor.js";
|
||||||
import { renderLesson, renderModuleList, renderLevelIndicator, updateActiveLessonInSidebar } from "./helpers/renderer.js";
|
import { renderLesson, renderModuleList, renderLevelIndicator, updateActiveLessonInSidebar, renderDifficultyBadge } from "./helpers/renderer.js";
|
||||||
import { loadModules } from "./config/lessons.js";
|
import { loadModules } from "./config/lessons.js";
|
||||||
import { initI18n, t, getLanguage, setLanguage, applyTranslations } from "./i18n.js";
|
import { initI18n, t, getLanguage, setLanguage, applyTranslations } from "./i18n.js";
|
||||||
import { parseHash, updateHash, replaceHash, getShareableUrl, RouteType, navigateTo } from "./helpers/router.js";
|
import { parseHash, updateHash, replaceHash, getShareableUrl, RouteType, navigateTo } from "./helpers/router.js";
|
||||||
@@ -164,7 +164,8 @@ const elements = {
|
|||||||
refFooterLessonLinks: document.getElementById("ref-footer-lesson-links"),
|
refFooterLessonLinks: document.getElementById("ref-footer-lesson-links"),
|
||||||
sectionFooterLessonLinks: document.getElementById("section-footer-lesson-links"),
|
sectionFooterLessonLinks: document.getElementById("section-footer-lesson-links"),
|
||||||
progressFill: document.getElementById("progress-fill"),
|
progressFill: document.getElementById("progress-fill"),
|
||||||
progressText: document.getElementById("progress-text"),
|
progressCurrent: document.getElementById("progress-current"),
|
||||||
|
progressTotal: document.getElementById("progress-total"),
|
||||||
milestonesContainer: document.getElementById("milestones"),
|
milestonesContainer: document.getElementById("milestones"),
|
||||||
resetBtn: document.getElementById("reset-btn"),
|
resetBtn: document.getElementById("reset-btn"),
|
||||||
disableFeedbackToggle: document.getElementById("disable-feedback-toggle"),
|
disableFeedbackToggle: document.getElementById("disable-feedback-toggle"),
|
||||||
@@ -317,14 +318,17 @@ let lastMilestoneReached = 0;
|
|||||||
function updateProgressDisplay() {
|
function updateProgressDisplay() {
|
||||||
const stats = lessonEngine.getProgressStats();
|
const stats = lessonEngine.getProgressStats();
|
||||||
|
|
||||||
// Update progress bar - shows overall progress with full gradient
|
// Update progress bar - shows progress towards next milestone
|
||||||
const progressPercent = stats.percentComplete || 1;
|
// CSS variable scales gradient so only first X% of colors show
|
||||||
|
const progressPercent = stats.progressToNext || 1;
|
||||||
elements.progressFill.style.width = `${progressPercent}%`;
|
elements.progressFill.style.width = `${progressPercent}%`;
|
||||||
elements.progressFill.style.setProperty('--progress-percent', progressPercent);
|
elements.progressFill.style.setProperty('--progress-percent', progressPercent);
|
||||||
|
|
||||||
// Update progress text - show completed of total lessons
|
// Update progress current - show progress towards next milestone
|
||||||
elements.progressText.textContent = t("progressTextMilestone", {
|
elements.progressCurrent.textContent = `${stats.totalCompleted}/${stats.nextMilestone}`;
|
||||||
completed: stats.totalCompleted,
|
|
||||||
|
// Update progress total - show total lessons
|
||||||
|
elements.progressTotal.textContent = t("progressTotal", {
|
||||||
total: stats.totalLessons
|
total: stats.totalLessons
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -569,6 +573,16 @@ function updateEditorForMode(mode) {
|
|||||||
label: "CSS Editor",
|
label: "CSS Editor",
|
||||||
cmMode: "css"
|
cmMode: "css"
|
||||||
},
|
},
|
||||||
|
markdown: {
|
||||||
|
placeholder: "# Heading\n\nWrite your **Markdown** here...",
|
||||||
|
label: "Markdown Editor",
|
||||||
|
cmMode: "markdown"
|
||||||
|
},
|
||||||
|
javascript: {
|
||||||
|
placeholder: "// Write your JavaScript here...",
|
||||||
|
label: "JavaScript Editor",
|
||||||
|
cmMode: "javascript"
|
||||||
|
},
|
||||||
playground: {
|
playground: {
|
||||||
placeholder: "<style>\n /* CSS here */\n</style>\n\n<!-- HTML here -->",
|
placeholder: "<style>\n /* CSS here */\n</style>\n\n<!-- HTML here -->",
|
||||||
label: "HTML & CSS",
|
label: "HTML & CSS",
|
||||||
@@ -645,21 +659,28 @@ function loadCurrentLesson() {
|
|||||||
lesson
|
lesson
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Render difficulty badge
|
||||||
|
renderDifficultyBadge(elements.lessonTitleRow, lesson);
|
||||||
|
|
||||||
// Set user code in CodeMirror (clear history to prevent undo/redo across lessons)
|
// Set user code in CodeMirror (clear history to prevent undo/redo across lessons)
|
||||||
|
// Pass codePrefix/codeSuffix as read-only zones for CSS mode
|
||||||
if (codeEditor) {
|
if (codeEditor) {
|
||||||
codeEditor.setValueAndClearHistory(engineState.userCode);
|
const prefix = lesson.codePrefix || "";
|
||||||
|
const suffix = lesson.codeSuffix || "";
|
||||||
|
codeEditor.setValueAndClearHistory(engineState.userCode, prefix, suffix);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update Run button text based on completion status
|
// Update Run button text based on completion status
|
||||||
if (engineState.isCompleted) {
|
if (engineState.isCompleted) {
|
||||||
elements.runBtn.querySelector("span").textContent = t("rerun");
|
elements.runBtn.querySelector("span").textContent = t("rerun");
|
||||||
|
|
||||||
// Add completion badge if not present
|
// Add completion badge to difficulty-wrapper if not present
|
||||||
if (!document.querySelector(".completion-badge")) {
|
const wrapper = document.querySelector(".difficulty-wrapper");
|
||||||
|
if (wrapper && !wrapper.querySelector(".completion-badge")) {
|
||||||
const badge = document.createElement("span");
|
const badge = document.createElement("span");
|
||||||
badge.className = "completion-badge";
|
badge.className = "completion-badge";
|
||||||
badge.textContent = t("completed");
|
badge.textContent = t("completed");
|
||||||
elements.lessonTitleRow.appendChild(badge);
|
wrapper.appendChild(badge);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show gradient border and glow for completed lessons
|
// Show gradient border and glow for completed lessons
|
||||||
@@ -668,7 +689,7 @@ function loadCurrentLesson() {
|
|||||||
} else {
|
} else {
|
||||||
elements.runBtn.querySelector("span").textContent = t("run");
|
elements.runBtn.querySelector("span").textContent = t("run");
|
||||||
|
|
||||||
// Remove completion badge and border if exists
|
// Remove completion badge if exists
|
||||||
const badge = document.querySelector(".completion-badge");
|
const badge = document.querySelector(".completion-badge");
|
||||||
if (badge) badge.remove();
|
if (badge) badge.remove();
|
||||||
elements.previewWrapper?.classList.remove("completed-glow");
|
elements.previewWrapper?.classList.remove("completed-glow");
|
||||||
@@ -755,15 +776,11 @@ function updateNavigationButtons() {
|
|||||||
const engineState = lessonEngine.getCurrentState();
|
const engineState = lessonEngine.getCurrentState();
|
||||||
const isPlayground = engineState.lesson?.mode === "playground";
|
const isPlayground = engineState.lesson?.mode === "playground";
|
||||||
|
|
||||||
// Hide next button in playground mode
|
|
||||||
elements.nextBtn.classList.toggle("hidden", isPlayground);
|
|
||||||
elements.gameControls?.classList.toggle("centered", isPlayground);
|
|
||||||
|
|
||||||
// Update button states
|
// Update button states
|
||||||
elements.prevBtn.disabled = !engineState.canGoPrev;
|
elements.prevBtn.disabled = !engineState.canGoPrev;
|
||||||
elements.nextBtn.disabled = !engineState.canGoNext;
|
elements.nextBtn.disabled = isPlayground || !engineState.canGoNext;
|
||||||
elements.prevBtn.classList.toggle("btn-disabled", !engineState.canGoPrev);
|
elements.prevBtn.classList.toggle("btn-disabled", !engineState.canGoPrev);
|
||||||
elements.nextBtn.classList.toggle("btn-disabled", !engineState.canGoNext);
|
elements.nextBtn.classList.toggle("btn-disabled", isPlayground || !engineState.canGoNext);
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextLesson() {
|
function nextLesson() {
|
||||||
@@ -865,7 +882,7 @@ function loadRandomTemplate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function runCode() {
|
function runCode() {
|
||||||
const userCode = codeEditor ? codeEditor.getValue() : "";
|
const userCode = codeEditor ? codeEditor.getEditableValue() : "";
|
||||||
const engineState = lessonEngine.getCurrentState();
|
const engineState = lessonEngine.getCurrentState();
|
||||||
const isPlayground = engineState.lesson?.mode === "playground";
|
const isPlayground = engineState.lesson?.mode === "playground";
|
||||||
|
|
||||||
@@ -1402,6 +1419,143 @@ const sectionContent = {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
`,
|
||||||
|
markdown: `
|
||||||
|
<div class="section-overview">
|
||||||
|
<p><strong>Markdown</strong> is a lightweight markup language created by John Gruber in 2004. It lets you write formatted text using plain text syntax that's easy to read and write. Markdown is used everywhere—from GitHub READMEs to documentation, note-taking apps, and content management systems.</p>
|
||||||
|
<p>The beauty of Markdown is its simplicity: <code># Heading</code> creates a heading, <code>**bold**</code> makes text bold, and <code>[link](url)</code> creates a link. No complex HTML tags needed. Markdown files can be converted to HTML, PDF, or many other formats.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topic-row">
|
||||||
|
<div class="topic-text">
|
||||||
|
<h2>Headings & Structure</h2>
|
||||||
|
<p>Create document structure with headings using <code>#</code> symbols. One <code>#</code> for h1, two <code>##</code> for h2, up to six levels. This creates a clear hierarchy in your documents.</p>
|
||||||
|
<p>
|
||||||
|
<a href="#markdown-basics/0" class="topic-link">Practice headings →</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="topic-code">
|
||||||
|
<div class="code-block">
|
||||||
|
<pre><code># Main Title
|
||||||
|
## Section
|
||||||
|
### Subsection
|
||||||
|
#### Detail</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topic-row">
|
||||||
|
<div class="topic-text">
|
||||||
|
<h2>Text Formatting</h2>
|
||||||
|
<p>Emphasize text with <code>**bold**</code> or <code>*italic*</code>. Combine them with <code>***bold italic***</code>. Use backticks for <code>\`inline code\`</code> to highlight commands or code snippets in your text.</p>
|
||||||
|
<p>
|
||||||
|
<a href="#markdown-basics/2" class="topic-link">Practice formatting →</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="topic-code">
|
||||||
|
<div class="code-block">
|
||||||
|
<pre><code>This is **bold** text.
|
||||||
|
This is *italic* text.
|
||||||
|
This is \`inline code\`.</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topic-row">
|
||||||
|
<div class="topic-text">
|
||||||
|
<h2>Lists</h2>
|
||||||
|
<p>Create bullet lists with <code>-</code>, <code>*</code>, or <code>+</code>. Numbered lists use <code>1.</code>, <code>2.</code>, etc. Indent items with spaces to create nested lists for complex outlines.</p>
|
||||||
|
<p>
|
||||||
|
<a href="#markdown-basics/4" class="topic-link">Practice lists →</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="topic-code">
|
||||||
|
<div class="code-block">
|
||||||
|
<pre><code>- First item
|
||||||
|
- Second item
|
||||||
|
- Nested item
|
||||||
|
|
||||||
|
1. Step one
|
||||||
|
2. Step two
|
||||||
|
3. Step three</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topic-row">
|
||||||
|
<div class="topic-text">
|
||||||
|
<h2>Links & Images</h2>
|
||||||
|
<p>Create links with <code>[text](url)</code> syntax. Images use the same format with an exclamation mark: <code></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)
|
||||||
|
|
||||||
|
</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
javascript: `
|
||||||
|
<div class="section-overview">
|
||||||
|
<p><strong>JavaScript</strong> is the programming language of the web. It adds interactivity to HTML pages—responding to clicks, updating content dynamically, validating forms, and much more. Every modern browser includes a JavaScript engine, making it the most widely deployed programming language in the world.</p>
|
||||||
|
<p>These beginner lessons cover the fundamentals: declaring variables, selecting and modifying DOM elements, and handling user events. Each concept builds on the previous one, giving you the tools to make any web page interactive.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topic-row">
|
||||||
|
<div class="topic-text">
|
||||||
|
<h2>Variables & Data Types</h2>
|
||||||
|
<p>JavaScript uses <code>const</code> for values that won't change and <code>let</code> for values that will. Template literals with backticks make it easy to embed expressions in strings using <code>\${...}</code> syntax.</p>
|
||||||
|
<p>Arrays store ordered collections in square brackets. Objects store key-value pairs in curly braces. These are the building blocks of every JavaScript program.</p>
|
||||||
|
<a href="#js-variables/0" class="topic-link">Learn JS Variables</a>
|
||||||
|
</div>
|
||||||
|
<div class="topic-code">
|
||||||
|
<div class="code-block">
|
||||||
|
<pre><code>const name = "Alice";
|
||||||
|
let count = 0;
|
||||||
|
count = count + 1;
|
||||||
|
|
||||||
|
const msg = \`Hello, \${name}!\`;
|
||||||
|
const colors = ["red", "green"];</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topic-row">
|
||||||
|
<div class="topic-text">
|
||||||
|
<h2>DOM Manipulation</h2>
|
||||||
|
<p>The DOM (Document Object Model) is how JavaScript sees your HTML. Use <code>document.querySelector()</code> to find elements by CSS selector, then modify them with properties like <code>textContent</code>, <code>style</code>, and <code>classList</code>.</p>
|
||||||
|
<a href="#js-dom/0" class="topic-link">Practice DOM Methods</a>
|
||||||
|
</div>
|
||||||
|
<div class="topic-code">
|
||||||
|
<div class="code-block">
|
||||||
|
<pre><code>const title = document.querySelector('h1');
|
||||||
|
title.textContent = "New Title";
|
||||||
|
title.style.color = "coral";
|
||||||
|
title.classList.add("active");</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topic-row">
|
||||||
|
<div class="topic-text">
|
||||||
|
<h2>Event Handling</h2>
|
||||||
|
<p>Events let your code respond to user actions. Use <code>addEventListener()</code> to run a function when something happens—a click, a keystroke, or an input change. The callback receives an event object with details about what happened.</p>
|
||||||
|
<a href="#js-events/0" class="topic-link">Handle Events</a>
|
||||||
|
</div>
|
||||||
|
<div class="topic-code">
|
||||||
|
<div class="code-block">
|
||||||
|
<pre><code>const btn = document.querySelector('.btn');
|
||||||
|
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
alert('Clicked!');
|
||||||
|
});</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
`
|
`
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1918,6 +2072,105 @@ const referenceContent = {
|
|||||||
</section>
|
</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></code></td><td>Image</td><td>Alt text for accessibility</td></tr>
|
||||||
|
<tr><td><code><url></code></td><td>Auto-link</td><td>URLs become clickable</td></tr>
|
||||||
|
<tr><td><code>[ref]: url</code></td><td>Reference link</td><td>Define at doc bottom</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="ref-section">
|
||||||
|
<h2>Code Blocks</h2>
|
||||||
|
<table class="ref-table">
|
||||||
|
<thead><tr><th>Syntax</th><th>Purpose</th><th>Notes</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>\`\`\`</code></td><td>Fenced code</td><td>3 backticks or tildes</td></tr>
|
||||||
|
<tr><td><code>\`\`\`js</code></td><td>Syntax highlight</td><td>Add language identifier</td></tr>
|
||||||
|
<tr><td><code> code</code></td><td>Indented code</td><td>4-space indent</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="ref-section">
|
||||||
|
<h2>Block Elements</h2>
|
||||||
|
<table class="ref-table">
|
||||||
|
<thead><tr><th>Syntax</th><th>Element</th><th>Notes</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>> Quote</code></td><td>Blockquote</td><td>Nest with >></td></tr>
|
||||||
|
<tr><td><code>---</code></td><td>Horizontal rule</td><td>Or *** or ___</td></tr>
|
||||||
|
<tr><td><code>| A | B |</code></td><td>Table</td><td>GFM extension</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="ref-section">
|
||||||
|
<h2>Tables (GFM)</h2>
|
||||||
|
<div class="ref-example">
|
||||||
|
<pre><code>| Header 1 | Header 2 |
|
||||||
|
|----------|----------|
|
||||||
|
| Cell 1 | Cell 2 |
|
||||||
|
| Cell 3 | Cell 4 |</code></pre>
|
||||||
|
</div>
|
||||||
|
<p>Use colons for alignment: <code>:---</code> (left), <code>:---:</code> (center), <code>---:</code> (right)</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="ref-see-also">Learn: <a href="#markdown">Markdown Section</a> | Also try: <a href="#html">HTML Elements</a></p>
|
||||||
`
|
`
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1963,7 +2216,7 @@ function updatePageMeta(route) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case RouteType.SECTION: {
|
case RouteType.SECTION: {
|
||||||
const sectionNames = { css: "CSS", html: "HTML", tailwind: "Tailwind CSS" };
|
const sectionNames = { css: "CSS", html: "HTML", tailwind: "Tailwind CSS", markdown: "Markdown" };
|
||||||
const sectionName = sectionNames[route.sectionId] || route.sectionId;
|
const sectionName = sectionNames[route.sectionId] || route.sectionId;
|
||||||
title = `${sectionName} Lessons - CODE CRISPIES | Learn ${sectionName}`;
|
title = `${sectionName} Lessons - CODE CRISPIES | Learn ${sectionName}`;
|
||||||
description = `Learn ${sectionName} through interactive coding exercises. Hands-on practice with instant feedback.`;
|
description = `Learn ${sectionName} through interactive coding exercises. Hands-on practice with instant feedback.`;
|
||||||
@@ -1987,7 +2240,8 @@ function updatePageMeta(route) {
|
|||||||
selectors: "CSS Selectors",
|
selectors: "CSS Selectors",
|
||||||
flexbox: "Flexbox",
|
flexbox: "Flexbox",
|
||||||
grid: "CSS Grid",
|
grid: "CSS Grid",
|
||||||
html: "HTML Elements"
|
html: "HTML Elements",
|
||||||
|
markdown: "Markdown Syntax"
|
||||||
};
|
};
|
||||||
const refName = refNames[route.refId] || "Reference";
|
const refName = refNames[route.refId] || "Reference";
|
||||||
title = `${refName} Reference - CODE CRISPIES`;
|
title = `${refName} Reference - CODE CRISPIES`;
|
||||||
@@ -2119,7 +2373,7 @@ function showLandingPage() {
|
|||||||
*/
|
*/
|
||||||
function renderFooterLessonLinks() {
|
function renderFooterLessonLinks() {
|
||||||
const modules = lessonEngine.modules || [];
|
const modules = lessonEngine.modules || [];
|
||||||
const sectionGroups = { css: [], html: [] };
|
const sectionGroups = { css: [], html: [], javascript: [] };
|
||||||
|
|
||||||
modules.forEach((module) => {
|
modules.forEach((module) => {
|
||||||
if (module.excludeFromProgress) return;
|
if (module.excludeFromProgress) return;
|
||||||
@@ -2156,7 +2410,7 @@ function renderFooterLessonLinks() {
|
|||||||
* Update progress indicators on landing page
|
* Update progress indicators on landing page
|
||||||
*/
|
*/
|
||||||
function updateLandingProgress() {
|
function updateLandingProgress() {
|
||||||
["css", "html", "tailwind"].forEach((sectionId) => {
|
["css", "html", "markdown", "javascript"].forEach((sectionId) => { // tailwind temporarily disabled
|
||||||
const progressEl = document.getElementById(`${sectionId}-progress`);
|
const progressEl = document.getElementById(`${sectionId}-progress`);
|
||||||
if (progressEl) {
|
if (progressEl) {
|
||||||
const sectionModules = getModulesBySection(lessonEngine.modules, sectionId);
|
const sectionModules = getModulesBySection(lessonEngine.modules, sectionId);
|
||||||
@@ -2242,7 +2496,7 @@ function showReferencePage(refId) {
|
|||||||
const activeRef = refId || "css";
|
const activeRef = refId || "css";
|
||||||
|
|
||||||
// Map reference to section for color coding
|
// Map reference to section for color coding
|
||||||
const refToSection = { css: "css", selectors: "css", flexbox: "css", grid: "css", html: "html" };
|
const refToSection = { css: "css", selectors: "css", flexbox: "css", grid: "css", html: "html", markdown: "markdown" };
|
||||||
updateSectionColor(refToSection[activeRef] || "css");
|
updateSectionColor(refToSection[activeRef] || "css");
|
||||||
|
|
||||||
// Track reference page view
|
// Track reference page view
|
||||||
@@ -2448,6 +2702,11 @@ function init() {
|
|||||||
// Initialize i18n before anything else
|
// Initialize i18n before anything else
|
||||||
initI18n();
|
initI18n();
|
||||||
|
|
||||||
|
// Set dynamic year in footer
|
||||||
|
document.querySelectorAll(".current-year").forEach((el) => {
|
||||||
|
el.textContent = new Date().getFullYear();
|
||||||
|
});
|
||||||
|
|
||||||
loadUserSettings();
|
loadUserSettings();
|
||||||
|
|
||||||
// Restore cached lesson content immediately to avoid "Loading..." flash
|
// Restore cached lesson content immediately to avoid "Loading..." flash
|
||||||
@@ -2476,6 +2735,11 @@ function init() {
|
|||||||
elements.closeSidebar.addEventListener("click", closeSidebar);
|
elements.closeSidebar.addEventListener("click", closeSidebar);
|
||||||
elements.sidebarBackdrop.addEventListener("click", closeSidebar);
|
elements.sidebarBackdrop.addEventListener("click", closeSidebar);
|
||||||
|
|
||||||
|
// Sidebar nav links (mobile) - close sidebar on click
|
||||||
|
document.querySelectorAll(".sidebar-nav-link").forEach((link) => {
|
||||||
|
link.addEventListener("click", closeSidebar);
|
||||||
|
});
|
||||||
|
|
||||||
// Logo click - navigate to home landing
|
// Logo click - navigate to home landing
|
||||||
elements.logoLink.addEventListener("click", (e) => {
|
elements.logoLink.addEventListener("click", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
10
src/auth.js
10
src/auth.js
@@ -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
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ import gradientsEN from "../../lessons/09-gradients.json";
|
|||||||
import filtersEN from "../../lessons/11-filters.json";
|
import filtersEN from "../../lessons/11-filters.json";
|
||||||
import positioningEN from "../../lessons/12-positioning.json";
|
import positioningEN from "../../lessons/12-positioning.json";
|
||||||
import pseudoElementsEN from "../../lessons/13-pseudo-elements.json";
|
import pseudoElementsEN from "../../lessons/13-pseudo-elements.json";
|
||||||
|
import markdownBasicsEN from "../../lessons/40-markdown-basics.json";
|
||||||
|
import jsVariablesEN from "../../lessons/50-js-variables.json";
|
||||||
|
import jsDomEN from "../../lessons/51-js-dom.json";
|
||||||
|
import jsEventsEN from "../../lessons/52-js-events.json";
|
||||||
import playgroundEN from "../../lessons/98-playground.json";
|
import playgroundEN from "../../lessons/98-playground.json";
|
||||||
import goodbyeEN from "../../lessons/99-goodbye.json";
|
import goodbyeEN from "../../lessons/99-goodbye.json";
|
||||||
|
|
||||||
@@ -162,6 +166,12 @@ const moduleStoreEN = [
|
|||||||
htmlFieldsetEN,
|
htmlFieldsetEN,
|
||||||
htmlDatalistEN,
|
htmlDatalistEN,
|
||||||
htmlTablesEN,
|
htmlTablesEN,
|
||||||
|
// Markdown
|
||||||
|
markdownBasicsEN,
|
||||||
|
// JavaScript
|
||||||
|
jsVariablesEN,
|
||||||
|
jsDomEN,
|
||||||
|
jsEventsEN,
|
||||||
// Outro
|
// Outro
|
||||||
goodbyeEN,
|
goodbyeEN,
|
||||||
playgroundEN
|
playgroundEN
|
||||||
@@ -201,6 +211,12 @@ const moduleStoreDE = [
|
|||||||
htmlFieldsetDE,
|
htmlFieldsetDE,
|
||||||
htmlDatalistDE,
|
htmlDatalistDE,
|
||||||
htmlTablesDE,
|
htmlTablesDE,
|
||||||
|
// Markdown
|
||||||
|
markdownBasicsEN, // Using EN fallback until translated
|
||||||
|
// JavaScript
|
||||||
|
jsVariablesEN, // Using EN fallback until translated
|
||||||
|
jsDomEN, // Using EN fallback until translated
|
||||||
|
jsEventsEN, // Using EN fallback until translated
|
||||||
// Outro
|
// Outro
|
||||||
goodbyeEN,
|
goodbyeEN,
|
||||||
playgroundEN
|
playgroundEN
|
||||||
@@ -240,6 +256,12 @@ const moduleStorePL = [
|
|||||||
htmlFieldsetPL,
|
htmlFieldsetPL,
|
||||||
htmlDatalistPL,
|
htmlDatalistPL,
|
||||||
htmlTablesPL,
|
htmlTablesPL,
|
||||||
|
// Markdown
|
||||||
|
markdownBasicsEN, // Using EN fallback until translated
|
||||||
|
// JavaScript
|
||||||
|
jsVariablesEN, // Using EN fallback until translated
|
||||||
|
jsDomEN, // Using EN fallback until translated
|
||||||
|
jsEventsEN, // Using EN fallback until translated
|
||||||
// Outro
|
// Outro
|
||||||
goodbyeEN,
|
goodbyeEN,
|
||||||
playgroundEN
|
playgroundEN
|
||||||
@@ -279,6 +301,12 @@ const moduleStoreES = [
|
|||||||
htmlFieldsetES,
|
htmlFieldsetES,
|
||||||
htmlDatalistES,
|
htmlDatalistES,
|
||||||
htmlTablesES,
|
htmlTablesES,
|
||||||
|
// Markdown
|
||||||
|
markdownBasicsEN, // Using EN fallback until translated
|
||||||
|
// JavaScript
|
||||||
|
jsVariablesEN, // Using EN fallback until translated
|
||||||
|
jsDomEN, // Using EN fallback until translated
|
||||||
|
jsEventsEN, // Using EN fallback until translated
|
||||||
// Outro
|
// Outro
|
||||||
goodbyeEN,
|
goodbyeEN,
|
||||||
playgroundEN
|
playgroundEN
|
||||||
@@ -318,6 +346,12 @@ const moduleStoreAR = [
|
|||||||
htmlFieldsetAR,
|
htmlFieldsetAR,
|
||||||
htmlDatalistAR,
|
htmlDatalistAR,
|
||||||
htmlTablesAR,
|
htmlTablesAR,
|
||||||
|
// Markdown
|
||||||
|
markdownBasicsEN, // Using EN fallback until translated
|
||||||
|
// JavaScript
|
||||||
|
jsVariablesEN, // Using EN fallback until translated
|
||||||
|
jsDomEN, // Using EN fallback until translated
|
||||||
|
jsEventsEN, // Using EN fallback until translated
|
||||||
// Outro
|
// Outro
|
||||||
goodbyeEN,
|
goodbyeEN,
|
||||||
playgroundEN
|
playgroundEN
|
||||||
@@ -357,6 +391,12 @@ const moduleStoreUK = [
|
|||||||
htmlFieldsetUK,
|
htmlFieldsetUK,
|
||||||
htmlDatalistUK,
|
htmlDatalistUK,
|
||||||
htmlTablesUK,
|
htmlTablesUK,
|
||||||
|
// Markdown
|
||||||
|
markdownBasicsEN, // Using EN fallback until translated
|
||||||
|
// JavaScript
|
||||||
|
jsVariablesEN, // Using EN fallback until translated
|
||||||
|
jsDomEN, // Using EN fallback until translated
|
||||||
|
jsEventsEN, // Using EN fallback until translated
|
||||||
// Outro
|
// Outro
|
||||||
goodbyeEN,
|
goodbyeEN,
|
||||||
playgroundEN
|
playgroundEN
|
||||||
|
|||||||
@@ -24,6 +24,20 @@ export const sections = {
|
|||||||
description: "Utility-first CSS framework",
|
description: "Utility-first CSS framework",
|
||||||
color: "#26a69a",
|
color: "#26a69a",
|
||||||
order: 3
|
order: 3
|
||||||
|
},
|
||||||
|
markdown: {
|
||||||
|
id: "markdown",
|
||||||
|
title: "Markdown",
|
||||||
|
description: "Lightweight markup language for formatting text",
|
||||||
|
color: "#5b8dd9",
|
||||||
|
order: 4
|
||||||
|
},
|
||||||
|
javascript: {
|
||||||
|
id: "javascript",
|
||||||
|
title: "JavaScript",
|
||||||
|
description: "Interactive scripting for web pages",
|
||||||
|
color: "#f0db4f",
|
||||||
|
order: 5
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,6 +71,8 @@ export function getModuleSection(module) {
|
|||||||
const mode = module.mode || "css";
|
const mode = module.mode || "css";
|
||||||
if (mode === "html") return "html";
|
if (mode === "html") return "html";
|
||||||
if (mode === "tailwind") return "tailwind";
|
if (mode === "tailwind") return "tailwind";
|
||||||
|
if (mode === "markdown") return "markdown";
|
||||||
|
if (mode === "javascript") return "javascript";
|
||||||
return "css";
|
return "css";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,50 @@
|
|||||||
* Renderer - Handles UI updates for the CSS learning platform
|
* Renderer - Handles UI updates for the CSS learning platform
|
||||||
*/
|
*/
|
||||||
import { t } from "../i18n.js";
|
import { t } from "../i18n.js";
|
||||||
|
import { getModuleSection, getSection, getSectionList } from "../config/sections.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute lesson difficulty based on lesson structure
|
||||||
|
* - Easy: selector is provided in codePrefix (student only writes properties)
|
||||||
|
* - Medium: student writes a simple selector (single element/class)
|
||||||
|
* - Hard: student writes compound selectors (descendant, chained classes, type+class)
|
||||||
|
* @param {Object} lesson - The lesson object
|
||||||
|
* @returns {"easy"|"medium"|"hard"} The computed difficulty
|
||||||
|
*/
|
||||||
|
export function computeLessonDifficulty(lesson) {
|
||||||
|
const codePrefix = lesson.codePrefix || "";
|
||||||
|
const solution = lesson.solution || "";
|
||||||
|
|
||||||
|
// If codePrefix contains an opening brace, selector is provided → Easy
|
||||||
|
if (codePrefix.includes("{")) {
|
||||||
|
return "easy";
|
||||||
|
}
|
||||||
|
|
||||||
|
// No codePrefix with selector - check the solution complexity
|
||||||
|
// Hard: descendant selectors (space before {), chained classes (.a.b), type+class (a.class)
|
||||||
|
const selectorMatch = solution.match(/^([^{]+)\{/);
|
||||||
|
if (selectorMatch) {
|
||||||
|
const selector = selectorMatch[1].trim();
|
||||||
|
|
||||||
|
// Descendant selector: has space (e.g., ".nav a", ".card p")
|
||||||
|
if (/\S\s+\S/.test(selector)) {
|
||||||
|
return "hard";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chained classes: multiple dots without space (e.g., ".btn.primary")
|
||||||
|
if ((selector.match(/\./g) || []).length > 1) {
|
||||||
|
return "hard";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type + class: element followed by dot (e.g., "a.btn", "div.card")
|
||||||
|
if (/^[a-z]+\.[a-z]/i.test(selector)) {
|
||||||
|
return "hard";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple selector → Medium
|
||||||
|
return "medium";
|
||||||
|
}
|
||||||
|
|
||||||
// Feedback elements cache
|
// Feedback elements cache
|
||||||
let feedbackElement = null;
|
let feedbackElement = null;
|
||||||
@@ -29,8 +73,24 @@ export function renderModuleList(container, modules, onSelectModule, onSelectLes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Group modules by section for headers
|
||||||
|
let currentSectionId = null;
|
||||||
|
|
||||||
// Create list items for each module
|
// Create list items for each module
|
||||||
modules.forEach((module) => {
|
modules.forEach((module) => {
|
||||||
|
// Insert section header when section changes
|
||||||
|
const sectionId = getModuleSection(module);
|
||||||
|
if (sectionId !== currentSectionId && !module.excludeFromProgress) {
|
||||||
|
currentSectionId = sectionId;
|
||||||
|
const section = getSection(sectionId);
|
||||||
|
if (section) {
|
||||||
|
const header = document.createElement("h3");
|
||||||
|
header.className = "sidebar-section-header";
|
||||||
|
header.textContent = section.title;
|
||||||
|
header.style.borderLeftColor = section.color;
|
||||||
|
container.appendChild(header);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Create module container
|
// Create module container
|
||||||
// Use native <details>/<summary> for expand/collapse
|
// Use native <details>/<summary> for expand/collapse
|
||||||
const moduleContainer = document.createElement("details");
|
const moduleContainer = document.createElement("details");
|
||||||
@@ -138,6 +198,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
|
||||||
|
|||||||
@@ -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", "javascript"]; // tailwind temporarily disabled
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Valid language codes for URL-based switching
|
* Valid language codes for URL-based switching
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export function validateUserCode(userCode, lesson) {
|
|||||||
return validateHtmlCode(userCode, lesson);
|
return validateHtmlCode(userCode, lesson);
|
||||||
case "tailwind":
|
case "tailwind":
|
||||||
return validateTailwindClasses(userCode, lesson);
|
return validateTailwindClasses(userCode, lesson);
|
||||||
|
case "javascript":
|
||||||
|
return validateJavaScriptCode(userCode, lesson);
|
||||||
case "css":
|
case "css":
|
||||||
default:
|
default:
|
||||||
return validateCssCode(userCode, lesson);
|
return validateCssCode(userCode, lesson);
|
||||||
@@ -204,6 +206,80 @@ function validateHtmlCode(userHtml, lesson) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate user JavaScript code against the lesson requirements
|
||||||
|
* @param {string} userCode - User submitted JavaScript code
|
||||||
|
* @param {Object} lesson - The current lesson object
|
||||||
|
* @returns {Object} Validation result with isValid and message properties
|
||||||
|
*/
|
||||||
|
function validateJavaScriptCode(userCode, lesson) {
|
||||||
|
if (!lesson || !lesson.validations) {
|
||||||
|
return { isValid: true, message: "No validations specified for this lesson." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validations = lesson.validations;
|
||||||
|
|
||||||
|
let result = {
|
||||||
|
isValid: true,
|
||||||
|
validCases: 0,
|
||||||
|
totalCases: validations.length,
|
||||||
|
message: "Your CODE looks CRISPY!"
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const validation of validations) {
|
||||||
|
const { type, value, message, options } = validation;
|
||||||
|
let validationPassed = false;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "contains":
|
||||||
|
validationPassed = containsValidation(userCode, value, options);
|
||||||
|
if (!validationPassed) {
|
||||||
|
result = {
|
||||||
|
...result,
|
||||||
|
isValid: false,
|
||||||
|
message: message || `Your code should include "${value}".`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "not_contains":
|
||||||
|
validationPassed = !containsValidation(userCode, value, options);
|
||||||
|
if (!validationPassed) {
|
||||||
|
result = {
|
||||||
|
...result,
|
||||||
|
isValid: false,
|
||||||
|
message: message || `Your code should not include "${value}".`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "regex":
|
||||||
|
validationPassed = regexValidation(userCode, value, options);
|
||||||
|
if (!validationPassed) {
|
||||||
|
result = {
|
||||||
|
...result,
|
||||||
|
isValid: false,
|
||||||
|
message: message || "Your code does not match the expected pattern."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn(`Unknown JavaScript validation type: ${type}`);
|
||||||
|
validationPassed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validationPassed) {
|
||||||
|
result.validCases++;
|
||||||
|
} else {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.validCases = validations.length;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
function validateTailwindClasses(userClasses, lesson) {
|
function validateTailwindClasses(userClasses, lesson) {
|
||||||
if (!lesson || !lesson.validations) {
|
if (!lesson || !lesson.validations) {
|
||||||
return { isValid: true, message: "No validations specified for this lesson." };
|
return { isValid: true, message: "No validations specified for this lesson." };
|
||||||
|
|||||||
68
src/i18n.js
68
src/i18n.js
@@ -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: "Безкоштовно та з відкритим кодом. Без реєстрації. Прогрес зберігається локально.",
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* CodeEditor - CodeMirror 6 wrapper with Emmet support
|
* CodeEditor - CodeMirror 6 wrapper with Emmet support
|
||||||
*/
|
*/
|
||||||
import { EditorState, Prec } from "@codemirror/state";
|
import { EditorState, EditorSelection, Prec, StateField, Compartment } from "@codemirror/state";
|
||||||
import { EditorView, keymap, placeholder } from "@codemirror/view";
|
import { EditorView, keymap, placeholder, Decoration } from "@codemirror/view";
|
||||||
import { defaultKeymap, historyKeymap, indentMore, indentLess, undo, redo } from "@codemirror/commands";
|
import { defaultKeymap, historyKeymap, indentMore, indentLess, undo, redo } from "@codemirror/commands";
|
||||||
import { history } from "@codemirror/commands";
|
import { history } from "@codemirror/commands";
|
||||||
import { html } from "@codemirror/lang-html";
|
import { html } from "@codemirror/lang-html";
|
||||||
import { css } from "@codemirror/lang-css";
|
import { css } from "@codemirror/lang-css";
|
||||||
|
import { markdown } from "@codemirror/lang-markdown";
|
||||||
|
import { javascript } from "@codemirror/lang-javascript";
|
||||||
import { autocompletion } from "@codemirror/autocomplete";
|
import { autocompletion } from "@codemirror/autocomplete";
|
||||||
import { abbreviationTracker, expandAbbreviation } from "@emmetio/codemirror6-plugin";
|
import { abbreviationTracker, expandAbbreviation } from "@emmetio/codemirror6-plugin";
|
||||||
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||||
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 +22,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 +36,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 +64,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 +80,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 +101,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 +148,135 @@ 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 === "javascript" ? javascript() : this.mode === "markdown" ? markdown() : css();
|
||||||
|
|
||||||
|
// Create read-only zones decorations
|
||||||
|
const readOnlyMark = Decoration.mark({ class: "cm-readonly-zone" });
|
||||||
|
|
||||||
|
// StateField to track and provide decorations for read-only zones
|
||||||
|
const readOnlyDecorations = StateField.define({
|
||||||
|
create: (state) => {
|
||||||
|
const decorations = [];
|
||||||
|
if (this.prefixLength > 0) {
|
||||||
|
decorations.push(readOnlyMark.range(0, this.prefixLength));
|
||||||
|
}
|
||||||
|
if (this.suffixLength > 0) {
|
||||||
|
const suffixStart = state.doc.length - this.suffixLength;
|
||||||
|
decorations.push(readOnlyMark.range(suffixStart, state.doc.length));
|
||||||
|
}
|
||||||
|
return Decoration.set(decorations);
|
||||||
|
},
|
||||||
|
update: (decorations, tr) => {
|
||||||
|
if (!tr.docChanged) return decorations;
|
||||||
|
// Recalculate decorations after document changes
|
||||||
|
const newDecorations = [];
|
||||||
|
if (this.prefixLength > 0) {
|
||||||
|
newDecorations.push(readOnlyMark.range(0, this.prefixLength));
|
||||||
|
}
|
||||||
|
if (this.suffixLength > 0) {
|
||||||
|
const suffixStart = tr.state.doc.length - this.suffixLength;
|
||||||
|
newDecorations.push(readOnlyMark.range(suffixStart, tr.state.doc.length));
|
||||||
|
}
|
||||||
|
return Decoration.set(newDecorations);
|
||||||
|
},
|
||||||
|
provide: (f) => EditorView.decorations.from(f)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Change filter to prevent edits in read-only zones
|
||||||
|
const readOnlyFilter = EditorState.changeFilter.of((tr) => {
|
||||||
|
// If no prefix/suffix, allow all changes
|
||||||
|
if (this.prefixLength === 0 && this.suffixLength === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefixEnd = this.prefixLength;
|
||||||
|
const suffixStart = tr.startState.doc.length - this.suffixLength;
|
||||||
|
|
||||||
|
// Check all change ranges - allow only changes within [prefixEnd, suffixStart]
|
||||||
|
let blocked = false;
|
||||||
|
tr.changes.iterChangedRanges((fromA, toA) => {
|
||||||
|
// Block if change starts in prefix zone
|
||||||
|
if (fromA < prefixEnd) {
|
||||||
|
blocked = true;
|
||||||
|
}
|
||||||
|
// Block if change extends into suffix zone
|
||||||
|
if (toA > suffixStart) {
|
||||||
|
blocked = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return !blocked;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transaction filter to constrain cursor/selection to editable area
|
||||||
|
const cursorFilter = EditorState.transactionFilter.of((tr) => {
|
||||||
|
// If no prefix/suffix, no constraints needed
|
||||||
|
if (this.prefixLength === 0 && this.suffixLength === 0) {
|
||||||
|
return tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefixEnd = this.prefixLength;
|
||||||
|
const suffixStart = tr.newDoc.length - this.suffixLength;
|
||||||
|
|
||||||
|
// Check if selection needs adjustment
|
||||||
|
const selection = tr.newSelection;
|
||||||
|
let needsAdjustment = false;
|
||||||
|
|
||||||
|
for (const range of selection.ranges) {
|
||||||
|
if (range.from < prefixEnd || range.to > suffixStart) {
|
||||||
|
needsAdjustment = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!needsAdjustment) {
|
||||||
|
return tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp selection to editable area
|
||||||
|
const newRanges = selection.ranges.map((range) => {
|
||||||
|
const from = Math.max(prefixEnd, Math.min(suffixStart, range.from));
|
||||||
|
const to = Math.max(prefixEnd, Math.min(suffixStart, range.to));
|
||||||
|
return EditorSelection.range(from, to);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [tr, { selection: EditorSelection.create(newRanges, selection.mainIndex) }];
|
||||||
|
});
|
||||||
|
|
||||||
// Build extensions array
|
// Build extensions array
|
||||||
const extensions = [
|
const extensions = [
|
||||||
@@ -165,6 +285,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 +308,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 +332,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 +380,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 +395,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 +406,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,67 @@ export class LessonEngine {
|
|||||||
${htmlWithClasses}
|
${htmlWithClasses}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`);
|
`;
|
||||||
|
} else if (mode === "javascript") {
|
||||||
|
// For JavaScript mode, user code runs as a script against previewHTML
|
||||||
|
const { codePrefix, codeSuffix } = this.currentLesson;
|
||||||
|
const fullScript = `${codePrefix || ""}${this.userCode || ""}${codeSuffix || ""}`;
|
||||||
|
html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>html, body { min-height: 100%; margin: 0; }</style>
|
||||||
|
<style>${previewBaseCSS || ""}</style>
|
||||||
|
<style>${sandboxCSS || ""}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${previewHTML || ""}
|
||||||
|
<script>
|
||||||
|
try {
|
||||||
|
${fullScript}
|
||||||
|
} catch (e) {
|
||||||
|
document.body.innerHTML += '<pre style="color:red">' + e.message + '</pre>';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
} else if (mode === "markdown") {
|
||||||
|
// For Markdown mode, parse user code to HTML
|
||||||
|
const renderedHtml = marked.parse(this.userCode || "");
|
||||||
|
html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>html, body { min-height: 100%; margin: 0; }</style>
|
||||||
|
<style>${previewBaseCSS || ""}</style>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; line-height: 1.6; padding: 1rem; }
|
||||||
|
h1, h2, h3, h4, h5, h6 { margin: 1em 0 0.5em; line-height: 1.3; }
|
||||||
|
h1 { font-size: 2em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
|
||||||
|
h2 { font-size: 1.5em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
|
||||||
|
p { margin: 0.5em 0; }
|
||||||
|
ul, ol { margin: 0.5em 0; padding-left: 2em; }
|
||||||
|
li { margin: 0.25em 0; }
|
||||||
|
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-family: monospace; }
|
||||||
|
pre { background: #f4f4f4; padding: 1em; overflow-x: auto; border-radius: 4px; }
|
||||||
|
pre code { background: none; padding: 0; }
|
||||||
|
blockquote { margin: 0.5em 0; padding-left: 1em; border-left: 4px solid #ddd; color: #666; }
|
||||||
|
a { color: #0366d6; }
|
||||||
|
hr { border: none; border-top: 1px solid #eee; margin: 1em 0; }
|
||||||
|
img { max-width: 100%; }
|
||||||
|
</style>
|
||||||
|
<style>${sandboxCSS || ""}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${renderedHtml}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
} else {
|
} 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 +329,10 @@ export class LessonEngine {
|
|||||||
${previewHTML}
|
${previewHTML}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`);
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
iframeDoc.close();
|
iframe.srcdoc = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
injectTailwindClasses(html, userClasses) {
|
injectTailwindClasses(html, userClasses) {
|
||||||
@@ -308,6 +365,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 +373,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 +389,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 +406,68 @@ export class LessonEngine {
|
|||||||
${htmlWithClasses}
|
${htmlWithClasses}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`);
|
`;
|
||||||
|
} else if (mode === "javascript") {
|
||||||
|
// For JavaScript mode, solution code runs as a script against previewHTML
|
||||||
|
const { codePrefix, codeSuffix } = this.currentLesson;
|
||||||
|
const fullScript = `${codePrefix || ""}${solutionCode}${codeSuffix || ""}`;
|
||||||
|
html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>html, body { min-height: 100%; margin: 0; }</style>
|
||||||
|
<style>${previewBaseCSS || ""}</style>
|
||||||
|
<style>${sandboxCSS || ""}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${previewHTML || ""}
|
||||||
|
<script>
|
||||||
|
try {
|
||||||
|
${fullScript}
|
||||||
|
} catch (e) {
|
||||||
|
document.body.innerHTML += '<pre style="color:red">' + e.message + '</pre>';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
} else if (mode === "markdown") {
|
||||||
|
// For Markdown mode, parse solution to HTML
|
||||||
|
const renderedHtml = marked.parse(solutionCode || "");
|
||||||
|
html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>html, body { min-height: 100%; margin: 0; }</style>
|
||||||
|
<style>${previewBaseCSS || ""}</style>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; line-height: 1.6; padding: 1rem; }
|
||||||
|
h1, h2, h3, h4, h5, h6 { margin: 1em 0 0.5em; line-height: 1.3; }
|
||||||
|
h1 { font-size: 2em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
|
||||||
|
h2 { font-size: 1.5em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
|
||||||
|
p { margin: 0.5em 0; }
|
||||||
|
ul, ol { margin: 0.5em 0; padding-left: 2em; }
|
||||||
|
li { margin: 0.25em 0; }
|
||||||
|
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-family: monospace; }
|
||||||
|
pre { background: #f4f4f4; padding: 1em; overflow-x: auto; border-radius: 4px; }
|
||||||
|
pre code { background: none; padding: 0; }
|
||||||
|
blockquote { margin: 0.5em 0; padding-left: 1em; border-left: 4px solid #ddd; color: #666; }
|
||||||
|
a { color: #0366d6; }
|
||||||
|
hr { border: none; border-top: 1px solid #eee; margin: 1em 0; }
|
||||||
|
img { max-width: 100%; }
|
||||||
|
</style>
|
||||||
|
<style>${sandboxCSS || ""}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${renderedHtml}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
} else {
|
} 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 +480,10 @@ export class LessonEngine {
|
|||||||
${previewHTML}
|
${previewHTML}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`);
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
iframeDoc.close();
|
iframe.srcdoc = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,9 @@
|
|||||||
<nav class="main-nav" id="main-nav" aria-label="Main sections">
|
<nav class="main-nav" id="main-nav" aria-label="Main sections">
|
||||||
<a href="#css" class="nav-link" data-section="css">CSS</a>
|
<a href="#css" class="nav-link" data-section="css">CSS</a>
|
||||||
<a href="#html" class="nav-link" data-section="html">HTML</a>
|
<a href="#html" class="nav-link" data-section="html">HTML</a>
|
||||||
<a href="#tailwind" class="nav-link" data-section="tailwind">Tailwind</a>
|
<!-- <a href="#tailwind" class="nav-link" data-section="tailwind">Tailwind</a> -->
|
||||||
|
<a href="#markdown" class="nav-link" data-section="markdown">Markdown</a>
|
||||||
|
<a href="#javascript" class="nav-link" data-section="javascript">JavaScript</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 +165,30 @@
|
|||||||
<p data-i18n="landingHtmlDesc">Semantic markup and native elements</p>
|
<p data-i18n="landingHtmlDesc">Semantic markup and native elements</p>
|
||||||
<span class="section-card-progress" id="html-progress"></span>
|
<span class="section-card-progress" id="html-progress"></span>
|
||||||
</a>
|
</a>
|
||||||
|
<!-- Tailwind temporarily disabled
|
||||||
<a href="#tailwind" class="section-card" data-section="tailwind">
|
<a href="#tailwind" class="section-card" data-section="tailwind">
|
||||||
<div class="section-card-icon" style="background: #26a69a">TW</div>
|
<div class="section-card-icon" style="background: #26a69a">TW</div>
|
||||||
<h3>Tailwind CSS</h3>
|
<h3>Tailwind CSS</h3>
|
||||||
<p data-i18n="landingTailwindDesc">Utility-first CSS framework</p>
|
<p data-i18n="landingTailwindDesc">Utility-first CSS framework</p>
|
||||||
<span class="section-card-progress" id="tailwind-progress"></span>
|
<span class="section-card-progress" id="tailwind-progress"></span>
|
||||||
</a>
|
</a>
|
||||||
|
-->
|
||||||
|
<a href="#markdown" class="section-card" data-section="markdown">
|
||||||
|
<div class="section-card-icon" style="background: #5b8dd9">MD</div>
|
||||||
|
<h3>Markdown</h3>
|
||||||
|
<p data-i18n="landingMarkdownDesc">Lightweight markup for formatting text</p>
|
||||||
|
<span class="section-card-progress" id="markdown-progress"></span>
|
||||||
|
</a>
|
||||||
|
<a href="#javascript" class="section-card" data-section="javascript">
|
||||||
|
<div class="section-card-icon" style="background: #f0db4f; color: #333">JS</div>
|
||||||
|
<h3>JavaScript</h3>
|
||||||
|
<p data-i18n="landingJsDesc">Interactive scripting for web pages</p>
|
||||||
|
<span class="section-card-progress" id="javascript-progress"></span>
|
||||||
|
</a>
|
||||||
</div>
|
</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">
|
||||||
@@ -181,13 +201,6 @@
|
|||||||
<h3 data-i18n="comingSoonAchievementsTitle">Achievements</h3>
|
<h3 data-i18n="comingSoonAchievementsTitle">Achievements</h3>
|
||||||
<p data-i18n="comingSoonAchievementsText">Earn badges as you master new skills. Track your learning milestones.</p>
|
<p data-i18n="comingSoonAchievementsText">Earn badges as you master new skills. Track your learning milestones.</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="coming-soon-card">
|
|
||||||
<span class="coming-soon-icon">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg>
|
|
||||||
</span>
|
|
||||||
<h3 data-i18n="comingSoonJsTitle">JavaScript</h3>
|
|
||||||
<p data-i18n="comingSoonJsText">Interactive JavaScript lessons with live code execution and DOM manipulation.</p>
|
|
||||||
</article>
|
|
||||||
<article class="coming-soon-card">
|
<article class="coming-soon-card">
|
||||||
<span class="coming-soon-icon">
|
<span class="coming-soon-icon">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
||||||
@@ -214,12 +227,6 @@
|
|||||||
</div>
|
</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>© 2025 <a href="https://librete.ch">LibreTECH</a>. <span data-i18n="footerLicense">Open source under MIT License.</span></p>
|
<p>© <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>© 2025 <a href="https://librete.ch">LibreTECH</a>. <span data-i18n="footerLicense">Open source under MIT License.</span></p>
|
<p>© <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>© 2025 <a href="https://librete.ch">LibreTECH</a>. <span data-i18n="footerLicense">Open source under MIT License.</span></p>
|
<p>© <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,14 @@
|
|||||||
<button id="close-sidebar" class="close-btn" data-i18n-aria-label="closeMenu" aria-label="Close menu">×</button>
|
<button id="close-sidebar" class="close-btn" data-i18n-aria-label="closeMenu" aria-label="Close menu">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<nav class="sidebar-section sidebar-nav-mobile" aria-label="Learning paths">
|
||||||
|
<a href="#css" class="sidebar-nav-link" data-section="css">CSS</a>
|
||||||
|
<a href="#html" class="sidebar-nav-link" data-section="html">HTML</a>
|
||||||
|
<!-- <a href="#tailwind" class="sidebar-nav-link" data-section="tailwind">Tailwind</a> -->
|
||||||
|
<a href="#javascript" class="sidebar-nav-link" data-section="javascript">JavaScript</a>
|
||||||
|
<button id="auth-trigger-mobile" class="sidebar-nav-link sidebar-auth-link" data-i18n="authLogin">Log In</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
<h4 data-i18n="progress">Progress</h4>
|
<h4 data-i18n="progress">Progress</h4>
|
||||||
<div class="progress-display milestone-progress" id="progress-display">
|
<div class="progress-display milestone-progress" id="progress-display">
|
||||||
@@ -479,10 +495,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 +523,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">
|
||||||
|
|||||||
528
src/main.css
528
src/main.css
@@ -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,22 @@ kbd {
|
|||||||
background: #1aafb8;
|
background: #1aafb8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-section="markdown"] .logo h1 .code-text {
|
||||||
|
color: #5b8dd9;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-section="markdown"] .logo h1 .crispies-text {
|
||||||
|
background: #5b8dd9;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-section="javascript"] .logo h1 .code-text {
|
||||||
|
color: #d4a017;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-section="javascript"] .logo h1 .crispies-text {
|
||||||
|
background: #d4a017;
|
||||||
|
}
|
||||||
|
|
||||||
.help-toggle {
|
.help-toggle {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
@@ -308,6 +324,16 @@ kbd {
|
|||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#auth-trigger-header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
#auth-trigger-header {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ================= GAME LAYOUT ================= */
|
/* ================= GAME LAYOUT ================= */
|
||||||
.game-layout {
|
.game-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -374,6 +400,7 @@ kbd {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
background: var(--primary-bg-medium);
|
background: var(--primary-bg-medium);
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
|
min-width: 0;
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
@@ -385,12 +412,18 @@ kbd {
|
|||||||
.module-name {
|
.module-name {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-pill .level-indicator {
|
.module-pill .level-indicator {
|
||||||
color: var(--primary-dark);
|
color: var(--primary-dark);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lesson-title-row {
|
.lesson-title-row {
|
||||||
@@ -398,7 +431,13 @@ kbd {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-bottom: var(--spacing-sm);
|
margin-bottom: var(--spacing-sm);
|
||||||
flex-wrap: wrap;
|
}
|
||||||
|
|
||||||
|
.lesson-title-row .difficulty-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#lesson-title {
|
#lesson-title {
|
||||||
@@ -437,7 +476,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 +486,28 @@ kbd {
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.difficulty-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.difficulty-badge .bar {
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 1px;
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.difficulty-badge .bar:nth-child(1) { height: 6px; }
|
||||||
|
.difficulty-badge .bar:nth-child(2) { height: 9px; }
|
||||||
|
.difficulty-badge .bar:nth-child(3) { height: 12px; }
|
||||||
|
|
||||||
|
.difficulty-easy .bar:nth-child(1),
|
||||||
|
.difficulty-medium .bar:nth-child(1),
|
||||||
|
.difficulty-medium .bar:nth-child(2),
|
||||||
|
.difficulty-hard .bar { background: var(--light-text); }
|
||||||
|
|
||||||
.lesson-description {
|
.lesson-description {
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
@@ -548,6 +609,12 @@ kbd {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Read-only zones (codePrefix/codeSuffix) */
|
||||||
|
.cm-readonly-zone {
|
||||||
|
opacity: 0.5;
|
||||||
|
background: rgba(100, 100, 120, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
.editor-content .cm-scroller {
|
.editor-content .cm-scroller {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
@@ -655,7 +722,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 +735,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 +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 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 +832,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 +852,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 +960,9 @@ kbd {
|
|||||||
|
|
||||||
/* ================= GAME CONTROLS ================= */
|
/* ================= GAME CONTROLS ================= */
|
||||||
.game-controls {
|
.game-controls {
|
||||||
display: flex;
|
display: grid;
|
||||||
justify-content: space-between;
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
gap: var(--spacing-md);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: var(--spacing-md);
|
padding: var(--spacing-md);
|
||||||
background: var(--panel-bg);
|
background: var(--panel-bg);
|
||||||
@@ -902,8 +970,16 @@ kbd {
|
|||||||
box-shadow: 0 10px 20px -10px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 10px 20px -10px rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-controls.centered {
|
.game-controls #prev-btn {
|
||||||
justify-content: center;
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-controls .module-pill {
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-controls #next-btn {
|
||||||
|
justify-self: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================= SIDEBAR ================= */
|
/* ================= SIDEBAR ================= */
|
||||||
@@ -987,14 +1063,69 @@ kbd {
|
|||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile navigation in sidebar */
|
||||||
|
.sidebar-nav-mobile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-link {
|
||||||
|
display: block;
|
||||||
|
padding: 0.6rem var(--spacing-md);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-color);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-link:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-link:hover {
|
||||||
|
background: var(--primary-bg-light);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-auth-link {
|
||||||
|
width: calc(100% - 2 * var(--spacing-md));
|
||||||
|
margin: var(--spacing-sm) var(--spacing-md);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: var(--white-text);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-auth-link:hover {
|
||||||
|
background: var(--primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
.sidebar-nav-mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Make the lessons nav section fill available space */
|
/* Make the lessons nav section fill available space */
|
||||||
nav.sidebar-section {
|
nav.sidebar-section:not(.sidebar-nav-mobile) {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding-bottom: var(--spacing-md);
|
padding-bottom: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-mobile {
|
||||||
|
flex: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-section h4 {
|
.sidebar-section h4 {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1019,7 +1150,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 +1162,28 @@ nav.sidebar-section {
|
|||||||
color: var(--light-text);
|
color: var(--light-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.progress-bar-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-row .progress-bar {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-current {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-total {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--light-text);
|
||||||
|
}
|
||||||
|
|
||||||
/* Milestone Progress */
|
/* Milestone Progress */
|
||||||
.milestone-progress {
|
.milestone-progress {
|
||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-sm);
|
||||||
@@ -1060,20 +1213,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 {
|
||||||
@@ -1098,6 +1252,22 @@ nav.sidebar-section {
|
|||||||
animation: milestone-pop 0.5s ease-out;
|
animation: milestone-pop 0.5s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sidebar section grouping headers */
|
||||||
|
.sidebar-section-header {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--light-text);
|
||||||
|
padding: 0.75rem 0.75rem 0.25rem;
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section-header:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Module List in Sidebar */
|
/* Module List in Sidebar */
|
||||||
.module-list {
|
.module-list {
|
||||||
/* No max-height - parent nav.sidebar-section handles overflow */
|
/* No max-height - parent nav.sidebar-section handles overflow */
|
||||||
@@ -1357,8 +1527,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 +2613,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 +2864,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 +2974,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 +3371,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 +3616,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 +3634,25 @@ input:checked + .toggle-slider::before {
|
|||||||
--section-color-rgb: 26, 175, 184;
|
--section-color-rgb: 26, 175, 184;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Markdown Section - Blue */
|
||||||
|
[data-section="markdown"] {
|
||||||
|
--section-color: #5b8dd9;
|
||||||
|
--section-color-light: #7ba3e5;
|
||||||
|
--section-color-dark: #4070b8;
|
||||||
|
--section-color-rgb: 91, 141, 217;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* JavaScript Section - Gold */
|
||||||
|
[data-section="javascript"] {
|
||||||
|
--section-color: #d4a017;
|
||||||
|
--section-color-light: #e0b840;
|
||||||
|
--section-color-dark: #b08610;
|
||||||
|
--section-color-rgb: 212, 160, 23;
|
||||||
|
}
|
||||||
|
|
||||||
/* 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 +3663,18 @@ input:checked + .toggle-slider::before {
|
|||||||
color: #1aafb8;
|
color: #1aafb8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-link[data-section="markdown"] {
|
||||||
|
color: #5b8dd9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link[data-section="javascript"] {
|
||||||
|
color: #d4a017;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-link[data-section="css"]:hover,
|
.nav-link[data-section="css"]: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 +3689,26 @@ input:checked + .toggle-slider::before {
|
|||||||
color: #0d8f96;
|
color: #0d8f96;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-link[data-section="markdown"]:hover,
|
||||||
|
.nav-link[data-section="markdown"].active {
|
||||||
|
background: rgba(91, 141, 217, 0.1);
|
||||||
|
color: #4070b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link[data-section="javascript"]:hover,
|
||||||
|
.nav-link[data-section="javascript"].active {
|
||||||
|
background: rgba(212, 160, 23, 0.1);
|
||||||
|
color: #b08610;
|
||||||
|
}
|
||||||
|
|
||||||
/* 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 +3729,24 @@ body[data-section="tailwind"] .hint-progress {
|
|||||||
background: #1aafb8;
|
background: #1aafb8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body[data-section="markdown"] .hint {
|
||||||
|
background: rgba(91, 141, 217, 0.3);
|
||||||
|
border-left-color: #7ba3e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-section="markdown"] .hint-progress {
|
||||||
|
background: #5b8dd9;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-section="javascript"] .hint {
|
||||||
|
background: rgba(212, 160, 23, 0.3);
|
||||||
|
border-left-color: #e0b840;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-section="javascript"] .hint-progress {
|
||||||
|
background: #d4a017;
|
||||||
|
}
|
||||||
|
|
||||||
/* 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 +3760,20 @@ body[data-section="tailwind"] .hint-progress {
|
|||||||
border-right-color: #4db6ac;
|
border-right-color: #4db6ac;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] body[data-section="markdown"] .hint {
|
||||||
|
border-right-color: #7ba3e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] body[data-section="javascript"] .hint {
|
||||||
|
border-right-color: #e0b840;
|
||||||
|
}
|
||||||
|
|
||||||
/* Reference nav link colors */
|
/* Reference nav link colors */
|
||||||
.ref-nav-link[data-ref="css"],
|
.ref-nav-link[data-ref="css"],
|
||||||
.ref-nav-link[data-ref="selectors"],
|
.ref-nav-link[data-ref="selectors"],
|
||||||
.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 +3784,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 +3800,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 +3853,50 @@ body[data-section="tailwind"] .cm-editor .cm-activeLine {
|
|||||||
background-color: rgba(26, 175, 184, 0.08) !important;
|
background-color: rgba(26, 175, 184, 0.08) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body[data-section="markdown"] .cm-editor .cm-content {
|
||||||
|
caret-color: #5b8dd9 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-section="markdown"] .cm-editor .cm-cursor,
|
||||||
|
body[data-section="markdown"] .cm-editor .cm-dropCursor {
|
||||||
|
border-left-color: #5b8dd9 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-section="markdown"] .cm-editor .cm-selectionBackground,
|
||||||
|
body[data-section="markdown"] .cm-editor .cm-content ::selection {
|
||||||
|
background-color: rgba(91, 141, 217, 0.25) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-section="markdown"] .cm-editor .cm-activeLine {
|
||||||
|
background-color: rgba(91, 141, 217, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-section="javascript"] .cm-editor .cm-content {
|
||||||
|
caret-color: #d4a017 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-section="javascript"] .cm-editor .cm-cursor,
|
||||||
|
body[data-section="javascript"] .cm-editor .cm-dropCursor {
|
||||||
|
border-left-color: #d4a017 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-section="javascript"] .cm-editor .cm-selectionBackground,
|
||||||
|
body[data-section="javascript"] .cm-editor .cm-content ::selection {
|
||||||
|
background-color: rgba(212, 160, 23, 0.25) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-section="javascript"] .cm-editor .cm-activeLine {
|
||||||
|
background-color: rgba(212, 160, 23, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Module pill section colors */
|
/* 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 +3917,27 @@ body[data-section="tailwind"] .module-pill .level-indicator {
|
|||||||
color: #0d8f96;
|
color: #0d8f96;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body[data-section="markdown"] .module-pill {
|
||||||
|
background: rgba(91, 141, 217, 0.1);
|
||||||
|
color: #5b8dd9;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-section="markdown"] .module-pill .level-indicator {
|
||||||
|
color: #4070b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-section="javascript"] .module-pill {
|
||||||
|
background: rgba(212, 160, 23, 0.1);
|
||||||
|
color: #d4a017;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-section="javascript"] .module-pill .level-indicator {
|
||||||
|
color: #b08610;
|
||||||
|
}
|
||||||
|
|
||||||
/* 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 +3948,14 @@ body[data-section="tailwind"] .code-block {
|
|||||||
border-color: rgba(26, 175, 184, 0.4);
|
border-color: rgba(26, 175, 184, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body[data-section="markdown"] .code-block {
|
||||||
|
border-color: rgba(91, 141, 217, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-section="javascript"] .code-block {
|
||||||
|
border-color: rgba(212, 160, 23, 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 +3969,17 @@ body[data-section="tailwind"] .code-block .cm-editor .cm-line {
|
|||||||
color: #c0e0e8;
|
color: #c0e0e8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body[data-section="markdown"] .code-block .cm-editor .cm-line {
|
||||||
|
color: #c0d0e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-section="javascript"] .code-block .cm-editor .cm-line {
|
||||||
|
color: #e0d8b0;
|
||||||
|
}
|
||||||
|
|
||||||
/* 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 +3990,17 @@ body[data-section="tailwind"] .code-block .cm-editor .cm-line {
|
|||||||
background: rgba(26, 175, 184, 0.92);
|
background: rgba(26, 175, 184, 0.92);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-section="markdown"] .task-instruction {
|
||||||
|
background: rgba(91, 141, 217, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-section="javascript"] .task-instruction {
|
||||||
|
background: rgba(212, 160, 23, 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 +4011,17 @@ body[data-section="tailwind"] .section-progress-bar .progress-fill {
|
|||||||
background: #1aafb8;
|
background: #1aafb8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body[data-section="markdown"] .section-progress-bar .progress-fill {
|
||||||
|
background: #5b8dd9;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-section="javascript"] .section-progress-bar .progress-fill {
|
||||||
|
background: #d4a017;
|
||||||
|
}
|
||||||
|
|
||||||
/* 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 +4032,17 @@ body[data-section="tailwind"] .section-progress-bar .progress-fill {
|
|||||||
color: #1aafb8;
|
color: #1aafb8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-section="markdown"] .section-hero h1 {
|
||||||
|
color: #5b8dd9;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-section="javascript"] .section-hero h1 {
|
||||||
|
color: #d4a017;
|
||||||
|
}
|
||||||
|
|
||||||
/* 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 +4053,14 @@ body[data-section="tailwind"] #lesson-title {
|
|||||||
color: #1aafb8;
|
color: #1aafb8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body[data-section="markdown"] #lesson-title {
|
||||||
|
color: #5b8dd9;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-section="javascript"] #lesson-title {
|
||||||
|
color: #d4a017;
|
||||||
|
}
|
||||||
|
|
||||||
/* 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 {
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ describe("Lessons Config Module", () => {
|
|||||||
expect(moduleIds).toContain("css-basic-selectors");
|
expect(moduleIds).toContain("css-basic-selectors");
|
||||||
expect(moduleIds).toContain("box-model");
|
expect(moduleIds).toContain("box-model");
|
||||||
expect(moduleIds).toContain("flexbox");
|
expect(moduleIds).toContain("flexbox");
|
||||||
|
// JavaScript modules
|
||||||
|
expect(moduleIds).toContain("js-variables");
|
||||||
|
expect(moduleIds).toContain("js-dom");
|
||||||
|
expect(moduleIds).toContain("js-events");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should have mode set on each lesson", async () => {
|
test("should have mode set on each lesson", async () => {
|
||||||
@@ -27,7 +31,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", "javascript", "playground"]).toContain(lesson.mode);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
538
tests/unit/renderer-extended.test.js
Normal file
538
tests/unit/renderer-extended.test.js
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
234
tests/unit/router.test.js
Normal file
234
tests/unit/router.test.js
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { parseHash, updateHash, navigateTo, replaceHash, replaceTo, getShareableUrl, getSectionIds, RouteType } from "../../src/helpers/router.js";
|
||||||
|
|
||||||
|
describe("Router", () => {
|
||||||
|
let pushStateSpy;
|
||||||
|
let replaceStateSpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset hash
|
||||||
|
window.location.hash = "";
|
||||||
|
pushStateSpy = vi.spyOn(history, "pushState").mockImplementation(() => {});
|
||||||
|
replaceStateSpy = vi.spyOn(history, "replaceState").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
pushStateSpy.mockRestore();
|
||||||
|
replaceStateSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("RouteType", () => {
|
||||||
|
test("RouteType_Constants_CorrectValues", () => {
|
||||||
|
expect(RouteType.HOME).toBe("home");
|
||||||
|
expect(RouteType.SECTION).toBe("section");
|
||||||
|
expect(RouteType.REFERENCE).toBe("reference");
|
||||||
|
expect(RouteType.LESSON).toBe("lesson");
|
||||||
|
expect(RouteType.LANGUAGE).toBe("language");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseHash", () => {
|
||||||
|
test("parseHash_EmptyHash_ReturnsHome", () => {
|
||||||
|
window.location.hash = "";
|
||||||
|
const result = parseHash();
|
||||||
|
expect(result).toEqual({ type: RouteType.HOME });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseHash_HashOnly_ReturnsHome", () => {
|
||||||
|
window.location.hash = "#";
|
||||||
|
const result = parseHash();
|
||||||
|
expect(result).toEqual({ type: RouteType.HOME });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["de", "de"],
|
||||||
|
["pl", "pl"],
|
||||||
|
["ar", "ar"],
|
||||||
|
["es", "es"],
|
||||||
|
["en", "en"],
|
||||||
|
["uk", "uk"]
|
||||||
|
])("parseHash_LanguageCode_%s_ReturnsLanguageRoute", (code, expectedLang) => {
|
||||||
|
window.location.hash = `#${code}`;
|
||||||
|
const result = parseHash();
|
||||||
|
expect(result).toEqual({ type: RouteType.LANGUAGE, lang: expectedLang });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["css", "css"],
|
||||||
|
["html", "html"],
|
||||||
|
["markdown", "markdown"],
|
||||||
|
["javascript", "javascript"]
|
||||||
|
])("parseHash_SectionId_%s_ReturnsSectionRoute", (sectionId, expectedId) => {
|
||||||
|
window.location.hash = `#${sectionId}`;
|
||||||
|
const result = parseHash();
|
||||||
|
expect(result).toEqual({ type: RouteType.SECTION, sectionId: expectedId });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseHash_ReferenceWithoutSubpage_ReturnsReferenceRouteNullRefId", () => {
|
||||||
|
window.location.hash = "#reference";
|
||||||
|
const result = parseHash();
|
||||||
|
expect(result).toEqual({ type: RouteType.REFERENCE, refId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseHash_ReferenceWithSubpage_ReturnsReferenceRouteWithRefId", () => {
|
||||||
|
window.location.hash = "#reference/css";
|
||||||
|
const result = parseHash();
|
||||||
|
expect(result).toEqual({ type: RouteType.REFERENCE, refId: "css" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseHash_ReferenceWithFlexboxSubpage_ReturnsCorrectRefId", () => {
|
||||||
|
window.location.hash = "#reference/flexbox";
|
||||||
|
const result = parseHash();
|
||||||
|
expect(result).toEqual({ type: RouteType.REFERENCE, refId: "flexbox" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseHash_SingleUnknownSegment_ReturnsLessonWithIndex0", () => {
|
||||||
|
window.location.hash = "#flexbox";
|
||||||
|
const result = parseHash();
|
||||||
|
expect(result).toEqual({ type: RouteType.LESSON, moduleId: "flexbox", lessonIndex: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseHash_ModuleWithLessonIndex_ReturnsLessonRoute", () => {
|
||||||
|
window.location.hash = "#flexbox/2";
|
||||||
|
const result = parseHash();
|
||||||
|
expect(result).toEqual({ type: RouteType.LESSON, moduleId: "flexbox", lessonIndex: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseHash_ModuleWithIndex0_ReturnsLessonRoute", () => {
|
||||||
|
window.location.hash = "#box-model/0";
|
||||||
|
const result = parseHash();
|
||||||
|
expect(result).toEqual({ type: RouteType.LESSON, moduleId: "box-model", lessonIndex: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseHash_NegativeLessonIndex_ReturnsNull", () => {
|
||||||
|
window.location.hash = "#module/-1";
|
||||||
|
const result = parseHash();
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseHash_NonNumericLessonIndex_ReturnsNull", () => {
|
||||||
|
window.location.hash = "#module/abc";
|
||||||
|
const result = parseHash();
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseHash_ThreeOrMoreSegments_ReturnsNull", () => {
|
||||||
|
window.location.hash = "#a/b/c";
|
||||||
|
const result = parseHash();
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseHash_EmptyModuleIdWithIndex_ReturnsNull", () => {
|
||||||
|
// #/0 → parts = ["", "0"], moduleId is empty string (falsy)
|
||||||
|
window.location.hash = "#/0";
|
||||||
|
const result = parseHash();
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateHash", () => {
|
||||||
|
test("updateHash_NewHash_CallsPushState", () => {
|
||||||
|
window.location.hash = "";
|
||||||
|
updateHash("flexbox", 2);
|
||||||
|
expect(pushStateSpy).toHaveBeenCalledWith(null, "", "#flexbox/2");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updateHash_SameHash_DoesNotCallPushState", () => {
|
||||||
|
window.location.hash = "#flexbox/2";
|
||||||
|
updateHash("flexbox", 2);
|
||||||
|
expect(pushStateSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updateHash_DifferentModule_CallsPushState", () => {
|
||||||
|
window.location.hash = "#flexbox/0";
|
||||||
|
updateHash("box-model", 0);
|
||||||
|
expect(pushStateSpy).toHaveBeenCalledWith(null, "", "#box-model/0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("navigateTo", () => {
|
||||||
|
test("navigateTo_SectionRoute_CallsPushState", () => {
|
||||||
|
window.location.hash = "";
|
||||||
|
navigateTo("css");
|
||||||
|
expect(pushStateSpy).toHaveBeenCalledWith(null, "", "#css");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("navigateTo_EmptyRoute_NavigatesToHash", () => {
|
||||||
|
window.location.hash = "#something";
|
||||||
|
navigateTo("");
|
||||||
|
expect(pushStateSpy).toHaveBeenCalledWith(null, "", "#");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("navigateTo_SameHash_DoesNotCallPushState", () => {
|
||||||
|
window.location.hash = "#css";
|
||||||
|
navigateTo("css");
|
||||||
|
expect(pushStateSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("replaceHash", () => {
|
||||||
|
test("replaceHash_ValidArgs_CallsReplaceState", () => {
|
||||||
|
replaceHash("flexbox", 3);
|
||||||
|
expect(replaceStateSpy).toHaveBeenCalledWith(null, "", "#flexbox/3");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("replaceHash_Index0_FormatsCorrectly", () => {
|
||||||
|
replaceHash("box-model", 0);
|
||||||
|
expect(replaceStateSpy).toHaveBeenCalledWith(null, "", "#box-model/0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("replaceTo", () => {
|
||||||
|
test("replaceTo_Route_CallsReplaceState", () => {
|
||||||
|
replaceTo("css");
|
||||||
|
expect(replaceStateSpy).toHaveBeenCalledWith(null, "", "#css");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("replaceTo_EmptyRoute_ReplacesToHash", () => {
|
||||||
|
replaceTo("");
|
||||||
|
expect(replaceStateSpy).toHaveBeenCalledWith(null, "", "#");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("replaceTo_ReferenceRoute_FormatsCorrectly", () => {
|
||||||
|
replaceTo("reference/flexbox");
|
||||||
|
expect(replaceStateSpy).toHaveBeenCalledWith(null, "", "#reference/flexbox");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getShareableUrl", () => {
|
||||||
|
test("getShareableUrl_ValidArgs_ReturnsFullUrl", () => {
|
||||||
|
const url = getShareableUrl("flexbox", 2);
|
||||||
|
expect(url).toContain("#flexbox/2");
|
||||||
|
expect(url).toMatch(/^https?:\/\/.+#flexbox\/2$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getShareableUrl_Index0_IncludesIndex", () => {
|
||||||
|
const url = getShareableUrl("box-model", 0);
|
||||||
|
expect(url).toContain("#box-model/0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSectionIds", () => {
|
||||||
|
test("getSectionIds_ReturnsCopy_NotOriginalArray", () => {
|
||||||
|
const ids1 = getSectionIds();
|
||||||
|
const ids2 = getSectionIds();
|
||||||
|
expect(ids1).toEqual(ids2);
|
||||||
|
expect(ids1).not.toBe(ids2); // Different references
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getSectionIds_ContainsExpectedSections", () => {
|
||||||
|
const ids = getSectionIds();
|
||||||
|
expect(ids).toContain("css");
|
||||||
|
expect(ids).toContain("html");
|
||||||
|
expect(ids).toContain("markdown");
|
||||||
|
expect(ids).toContain("javascript");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getSectionIds_MutatingCopy_DoesNotAffectOriginal", () => {
|
||||||
|
const ids = getSectionIds();
|
||||||
|
ids.push("custom");
|
||||||
|
const freshIds = getSectionIds();
|
||||||
|
expect(freshIds).not.toContain("custom");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
176
tests/unit/sections.test.js
Normal file
176
tests/unit/sections.test.js
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { describe, test, expect } from "vitest";
|
||||||
|
import { sections, getSection, getSectionList, getModuleSection, getModulesBySection } from "../../src/config/sections.js";
|
||||||
|
|
||||||
|
describe("Sections Config", () => {
|
||||||
|
describe("sections constant", () => {
|
||||||
|
test("sections_AllDefined_HasFiveSections", () => {
|
||||||
|
expect(Object.keys(sections)).toHaveLength(5);
|
||||||
|
expect(sections).toHaveProperty("css");
|
||||||
|
expect(sections).toHaveProperty("html");
|
||||||
|
expect(sections).toHaveProperty("tailwind");
|
||||||
|
expect(sections).toHaveProperty("markdown");
|
||||||
|
expect(sections).toHaveProperty("javascript");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sections_EachSection_HasRequiredFields", () => {
|
||||||
|
for (const [key, section] of Object.entries(sections)) {
|
||||||
|
expect(section.id).toBe(key);
|
||||||
|
expect(section.title).toBeTruthy();
|
||||||
|
expect(section.description).toBeTruthy();
|
||||||
|
expect(section.color).toMatch(/^#[0-9a-f]{6}$/);
|
||||||
|
expect(typeof section.order).toBe("number");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSection", () => {
|
||||||
|
test.each([
|
||||||
|
["css", "CSS"],
|
||||||
|
["html", "HTML"],
|
||||||
|
["tailwind", "Tailwind CSS"],
|
||||||
|
["markdown", "Markdown"],
|
||||||
|
["javascript", "JavaScript"]
|
||||||
|
])("getSection_%s_ReturnsCorrectSection", (id, expectedTitle) => {
|
||||||
|
const section = getSection(id);
|
||||||
|
expect(section).not.toBeNull();
|
||||||
|
expect(section.id).toBe(id);
|
||||||
|
expect(section.title).toBe(expectedTitle);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getSection_NonExistentId_ReturnsNull", () => {
|
||||||
|
expect(getSection("nonexistent")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getSection_Undefined_ReturnsNull", () => {
|
||||||
|
expect(getSection(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getSection_EmptyString_ReturnsNull", () => {
|
||||||
|
expect(getSection("")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSectionList", () => {
|
||||||
|
test("getSectionList_Default_ReturnsSortedByOrder", () => {
|
||||||
|
const list = getSectionList();
|
||||||
|
expect(list).toHaveLength(5);
|
||||||
|
|
||||||
|
// Verify sorted by order
|
||||||
|
for (let i = 1; i < list.length; i++) {
|
||||||
|
expect(list[i].order).toBeGreaterThan(list[i - 1].order);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getSectionList_Default_CSSIsFirst", () => {
|
||||||
|
const list = getSectionList();
|
||||||
|
expect(list[0].id).toBe("css");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getSectionList_Default_JavaScriptIsLast", () => {
|
||||||
|
const list = getSectionList();
|
||||||
|
expect(list[list.length - 1].id).toBe("javascript");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getSectionList_Default_ContainsAllSections", () => {
|
||||||
|
const list = getSectionList();
|
||||||
|
const ids = list.map((s) => s.id);
|
||||||
|
expect(ids).toContain("css");
|
||||||
|
expect(ids).toContain("html");
|
||||||
|
expect(ids).toContain("tailwind");
|
||||||
|
expect(ids).toContain("markdown");
|
||||||
|
expect(ids).toContain("javascript");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getModuleSection", () => {
|
||||||
|
test("getModuleSection_ExplicitSection_UsesExplicitValue", () => {
|
||||||
|
const module = { mode: "css", section: "html" };
|
||||||
|
expect(getModuleSection(module)).toBe("html");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["css", "css"],
|
||||||
|
["html", "html"],
|
||||||
|
["tailwind", "tailwind"],
|
||||||
|
["markdown", "markdown"],
|
||||||
|
["javascript", "javascript"]
|
||||||
|
])("getModuleSection_Mode%s_InfersCorrectSection", (mode, expectedSection) => {
|
||||||
|
const module = { mode };
|
||||||
|
expect(getModuleSection(module)).toBe(expectedSection);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getModuleSection_NoMode_DefaultsToCss", () => {
|
||||||
|
expect(getModuleSection({})).toBe("css");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getModuleSection_UndefinedMode_DefaultsToCss", () => {
|
||||||
|
expect(getModuleSection({ mode: undefined })).toBe("css");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getModuleSection_UnknownMode_DefaultsToCss", () => {
|
||||||
|
expect(getModuleSection({ mode: "unknown-mode" })).toBe("css");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getModuleSection_ExplicitSectionOverridesMode_UsesSection", () => {
|
||||||
|
const module = { mode: "html", section: "tailwind" };
|
||||||
|
expect(getModuleSection(module)).toBe("tailwind");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getModulesBySection", () => {
|
||||||
|
const testModules = [
|
||||||
|
{ id: "css-basics", mode: "css" },
|
||||||
|
{ id: "flexbox", mode: "css" },
|
||||||
|
{ id: "html-elements", mode: "html" },
|
||||||
|
{ id: "tailwind-intro", mode: "tailwind" },
|
||||||
|
{ id: "markdown-basics", mode: "markdown" },
|
||||||
|
{ id: "welcome", mode: "css", excludeFromProgress: true },
|
||||||
|
{ id: "playground", mode: "css", excludeFromProgress: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
test("getModulesBySection_Css_ReturnsCssModules", () => {
|
||||||
|
const result = getModulesBySection(testModules, "css");
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map((m) => m.id)).toEqual(["css-basics", "flexbox"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getModulesBySection_Html_ReturnsHtmlModules", () => {
|
||||||
|
const result = getModulesBySection(testModules, "html");
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe("html-elements");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getModulesBySection_Tailwind_ReturnsTailwindModules", () => {
|
||||||
|
const result = getModulesBySection(testModules, "tailwind");
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe("tailwind-intro");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getModulesBySection_ExcludesFromProgress_FiltersOut", () => {
|
||||||
|
const result = getModulesBySection(testModules, "css");
|
||||||
|
const ids = result.map((m) => m.id);
|
||||||
|
expect(ids).not.toContain("welcome");
|
||||||
|
expect(ids).not.toContain("playground");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getModulesBySection_EmptyModules_ReturnsEmptyArray", () => {
|
||||||
|
const result = getModulesBySection([], "css");
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getModulesBySection_NonExistentSection_ReturnsEmptyArray", () => {
|
||||||
|
const result = getModulesBySection(testModules, "nonexistent");
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getModulesBySection_ExplicitSectionOverride_IncludesModule", () => {
|
||||||
|
const modules = [
|
||||||
|
{ id: "special", mode: "css", section: "html" },
|
||||||
|
{ id: "normal-html", mode: "html" }
|
||||||
|
];
|
||||||
|
const result = getModulesBySection(modules, "html");
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map((m) => m.id)).toContain("special");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
735
tests/unit/validator-extended.test.js
Normal file
735
tests/unit/validator-extended.test.js
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -226,6 +226,86 @@ describe("CSS Validator", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("JavaScript Validator", () => {
|
||||||
|
describe("validateUserCode with mode: javascript", () => {
|
||||||
|
it("should validate contains correctly for JavaScript", () => {
|
||||||
|
const userCode = 'const name = "Alice";';
|
||||||
|
const lesson = {
|
||||||
|
mode: "javascript",
|
||||||
|
validations: [{ type: "contains", value: "const", message: "Use const" }]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateUserCode(userCode, lesson);
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.validCases).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate regex correctly for JavaScript", () => {
|
||||||
|
const userCode = 'const name = "Alice";';
|
||||||
|
const lesson = {
|
||||||
|
mode: "javascript",
|
||||||
|
validations: [{ type: "regex", value: 'const\\s+name\\s*=', message: "Declare name" }]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateUserCode(userCode, lesson);
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate not_contains correctly for JavaScript", () => {
|
||||||
|
const userCode = 'const name = "Alice";';
|
||||||
|
const lesson = {
|
||||||
|
mode: "javascript",
|
||||||
|
validations: [{ type: "not_contains", value: "var", message: "Do not use var" }]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateUserCode(userCode, lesson);
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
|
||||||
|
const failCode = 'var name = "Alice";';
|
||||||
|
const failResult = validateUserCode(failCode, lesson);
|
||||||
|
expect(failResult.isValid).toBe(false);
|
||||||
|
expect(failResult.message).toBe("Do not use var");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return invalid for missing code", () => {
|
||||||
|
const userCode = "";
|
||||||
|
const lesson = {
|
||||||
|
mode: "javascript",
|
||||||
|
validations: [{ type: "contains", value: "const", message: "Use const" }]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateUserCode(userCode, lesson);
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass with no validations", () => {
|
||||||
|
const userCode = 'const x = 1;';
|
||||||
|
const lesson = { mode: "javascript" };
|
||||||
|
|
||||||
|
const result = validateUserCode(userCode, lesson);
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.message).toContain("No validations specified");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle multiple validations with early return on failure", () => {
|
||||||
|
const userCode = 'const name = "Alice";';
|
||||||
|
const lesson = {
|
||||||
|
mode: "javascript",
|
||||||
|
validations: [
|
||||||
|
{ type: "contains", value: "const", message: "Use const" },
|
||||||
|
{ type: "contains", value: "let", message: "Use let" },
|
||||||
|
{ type: "contains", value: "name", message: "Declare name" }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateUserCode(userCode, lesson);
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.message).toBe("Use let");
|
||||||
|
expect(result.validCases).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("HTML Validator", () => {
|
describe("HTML Validator", () => {
|
||||||
describe("validateUserCode with mode: html", () => {
|
describe("validateUserCode with mode: html", () => {
|
||||||
it("should validate element_exists correctly", () => {
|
it("should validate element_exists correctly", () => {
|
||||||
|
|||||||
376
wave.yaml
Normal file
376
wave.yaml
Normal 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
|
||||||
Reference in New Issue
Block a user