73 Commits

Author SHA1 Message Date
ae8f9fef45 feat: add JavaScript learning section with starter lessons and sidebar section headers
Implementation following plan:
- S01: Foundation: schema, section config, and router
- S02: Install CodeMirror JavaScript language support
- S03: Create JavaScript lesson JSON files (variables, DOM, events)
- S04: Register JavaScript lessons in module stores
- S05: Add JavaScript validation logic
- S06: Add JavaScript mode to LessonEngine preview rendering
- S07: Add JavaScript mode to CodeEditor
- S08: Update app.js for JavaScript mode support
- S09: Update navigation HTML and CSS theming for JavaScript section
- S10: Add section grouping headers in sidebar navigation
- S11: Update and write tests
2026-03-28 20:22:50 +01:00
372320b807 Merge pull request 'fix: rewrite flexbox tasks to describe outcomes instead of answers' (#5) from 003-flexbox-task-wording into main 2026-03-28 19:59:24 +01:00
61acd692f4 fix: rewrite flexbox task descriptions to describe outcomes instead of answers (#3)
Replace copy-pasteable CSS declarations in all 6 flexbox lesson tasks with
outcome-oriented descriptions. Update validation error messages to hint at
properties without revealing exact declarations. Add regex validation for
flexbox-6 to accept both flex: 1 and flex-grow: 1.
2026-03-28 19:25:32 +01:00
672a2d28cb docs: add spec, plan, and tasks for flexbox task wording fix (#3) 2026-03-28 19:17:48 +01:00
433379155b fix(security): add Content-Security-Policy meta tag
Restricts script sources to self and known CDNs, connect sources to
self and Supabase, blocks unauthorized resource loading. Allows
unsafe-inline for styles (CodeMirror requirement) and blob: for
sandboxed preview iframes.

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

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

Total: 43 → 225 tests
2026-03-28 16:14:52 +01:00
4476d26140 chore: update wave.yaml to latest wave version with corrected project config 2026-03-28 15:58:46 +01:00
f28531fb4c ignore auto claude files 2026-01-27 16:53:47 +01:00
7ab095718b chore(nix): add flake with claude-code in devShell 2026-01-25 21:28:16 +01:00
5a243f332a chore: temporarily disable Tailwind CSS section
- Comment out Tailwind nav links in header and sidebar
- Comment out Tailwind card on landing page
- Remove tailwind from router SECTIONS array
- Remove tailwind from landing page progress tracking

Tailwind content and styles remain in codebase for easy re-enabling.
2026-01-25 15:40:53 +01:00
739470e045 feat: add Markdown learning module with 8 beginner lessons
- Add markdown-basics module with lessons for headings, text formatting,
  lists, links, and inline code
- Integrate markdown section with blue color theme (#5b8dd9)
- Add markdown mode support in CodeEditor and LessonEngine
- Add markdown preview rendering using marked library
- Add section overview page with educational content
- Add markdown reference page with syntax guide
- Add i18n translations for 6 languages (EN, DE, PL, ES, AR, UK)
- Update router to recognize #markdown as section route
- Add all section-specific CSS styles for markdown theme
2026-01-25 11:27:07 +01:00
07aafa0d89 feat(app): pass codePrefix/codeSuffix to editor on lesson load
- Update loadCurrentLesson() to pass prefix/suffix to editor
- Use getEditableValue() in runCode() to get only user code
2026-01-25 02:00:07 +01:00
eb82eed826 style: add styling for read-only editor zones
Dimmed appearance with subtle background for codePrefix/codeSuffix regions.
2026-01-25 01:59:59 +01:00
82f6e46d3c feat(editor): add read-only zones support for codePrefix/codeSuffix
- Add initWithContext() method for prefix/suffix initialization
- Implement changeFilter to prevent edits in read-only zones
- Add transactionFilter to constrain cursor to editable area
- Add visual decorations with cm-readonly-zone class
- Update getValue/setValue to handle editable portions correctly
2026-01-25 00:39:09 +01:00
847b261f16 fix: restore gradient scaling and distribute milestone colors evenly 2026-01-16 23:45:19 +01:00
2ce88f9cb7 fix: milestone colors now correctly reflect position in 0-100 gradient 2026-01-16 23:40:38 +01:00
a8ef3d3c5c fix: progress bar now shows milestone progress instead of overall progress 2026-01-16 23:21:41 +01:00
0f5ac81fe8 fix: shorten German reset progress label to 'Fortschritt' 2026-01-16 22:01:15 +01:00
cf0d2cba51 feat: add lesson difficulty indicators and improve mobile sidebar
- Add computeLessonDifficulty function to determine lesson difficulty
  based on selector complexity (easy/medium/hard)
- Display difficulty badge with bar indicator in lesson title row
- Add mobile navigation links (CSS, HTML, Tailwind) to sidebar
- Add mobile auth trigger button in sidebar
- Redesign settings section with card layout and native toggles
- Add difficulty translations for all 6 languages
- Fix module pill overflow on narrow screens
2026-01-16 21:47:47 +01:00
d5bd23615f fix: update German CTA to 'Jetzt gleich anfangen' 2026-01-16 16:53:38 +01:00
fcc6748aae fix: update German landing hero text to 'Lerne Web Entwicklung mit CODE CRISPIES' 2026-01-16 16:41:17 +01:00
5c16a8a767 feat: redesign sidebar progress to show milestone progress and total lessons 2026-01-16 16:37:23 +01:00
17b3d5380d fix: show Next button disabled in playground instead of hiding it 2026-01-16 15:32:21 +01:00
f9311d83f7 fix: remove centered class toggle - grid layout handles positioning 2026-01-16 15:31:17 +01:00
f4ce61ba64 fix: add gap between game controls grid items 2026-01-16 15:29:44 +01:00
813d669302 fix: use CSS grid for game controls to keep pill centered when next is hidden 2026-01-16 15:28:45 +01:00
9328399dcb fix: change 'Crispy Code' to 'Code Crispy' in landing page title 2026-01-16 15:27:14 +01:00
857ae9c3ef fix: move device notice under section cards on landing page 2026-01-16 15:26:10 +01:00
c91e8d6f32 fix: make copyright year dynamic in footer 2026-01-16 15:25:04 +01:00
Michael Czechowski
9821e014c5 Merge pull request #2 from nextlevelshit/feature/new-lessons
Feature/new lessons
2026-01-16 15:20:31 +01:00
e0cee41482 feat: add more Umami tracking events
New events:
- run_code (with module, lesson, playground flag)
- logo_click
- privacy_open
- imprint_open
- setting_change (with setting name and value)
2026-01-16 15:18:23 +01:00
11877e8e7a feat: add Umami analytics tracking for auth events
Track the following events:
- auth_login (with method: email, github, google)
- auth_signup (with method: email)
- auth_logout
- auth_delete_account
2026-01-16 15:15:50 +01:00
d78f0ac0b4 feat: keep preview glow permanently after animation completes 2026-01-16 15:14:43 +01:00
0b22263a68 fix: keep preview glow visible after animation ends 2026-01-16 15:11:39 +01:00
baaf05dda4 feat: improve progress display with gradient milestones
- Progress text now says "X of Y lessons completed" in all languages
- Each milestone badge shows a portion of the gradient based on position
- Progress bar shows full gradient, cropped to current progress percentage

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-01-16 15:08:57 +01:00
2aa35cea2d fix: remove border-left from nav-link-ref, use margin instead 2026-01-16 15:05:46 +01:00
f0e2072ac7 feat: add section color coding to overview first paragraphs
The strong and code elements in .section-overview now use
section-specific colors (purple/pink/teal) instead of default purple.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-01-16 15:05:12 +01:00
072859459f fix: clean up OAuth debug logging
Remove temporary console.log statements used during OAuth debugging.
2026-01-16 15:04:24 +01:00
062659fa30 fix: simplify playground navigation - just hide Next button
Previous button stays "Previous" and works the same everywhere.
Only difference in playground: Next button is hidden.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-01-16 15:01:44 +01:00
a7dcb3ec6f fix: playground back button goes to previous lesson
The Back button in playground now works the same as Previous button
on other pages - it navigates to the previous lesson.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-01-16 15:00:13 +01:00
28d41344d1 fix: use same button position/style for playground back button
Instead of a separate back button, the Previous button is repurposed
as "Back" in playground mode - same position (left), same style.
Only the Next button is hidden in playground mode.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-01-16 14:58:25 +01:00
fb5fbe4107 feat: add back button to playground and fix dice icon color
- Add back button to game-controls (shown only in playground mode)
- Replace dice img with inline SVG using currentColor for consistent styling
- Add SVG sizing rule to .btn-icon
- Add "back" translation to all 6 languages

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-01-16 14:55:11 +01:00
7ecc115c55 feat: add section color coding to overview page elements
Topic headings (h2, h3) and inline code now use section-specific colors:
- CSS: purple
- HTML: pink
- Tailwind: teal

Uses CSS custom properties (--section-color-dark, --section-color-rgb)
that are already set per section.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-01-16 14:45:30 +01:00
d802172e5b feat: add section color coding to lesson title h2
The lesson title now uses section-specific colors:
- CSS: purple (#9163b8)
- HTML: pink (#d45aa0)
- Tailwind: teal (#1aafb8)

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-01-16 14:44:10 +01:00
73a0c59722 fix: show total lessons instead of next milestone in progress text
The progress text was showing "0 of 1" (next milestone) which was confusing.
Now shows "0 of 101" (total lessons) while milestone dots show the milestone progression.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-01-16 14:27:12 +01:00
630a0a6a21 docs: update roadmap with completed milestones and lesson counts 2026-01-16 14:17:51 +01:00
be9c753a0e feat: add new lesson modules and reach 101 total lessons
New CSS Modules:
- Gradients (3 lessons): linear-gradient, radial-gradient, direction
- Filters (4 lessons): blur, grayscale, brightness, drop-shadow
- Positioning (4 lessons): relative, absolute, offset properties
- Pseudo-elements (4 lessons): ::before, ::after, content, decorative

New HTML Module:
- Semantic HTML (3 lessons): article, section, aside

Expanded Existing Modules:
- Typography: +2 lessons (text-decoration, text-shadow)
- Tables: +2 lessons (thead/tbody/tfoot, colspan)

Total lessons: 101 (up from ~66)
- Enables full milestone system (1, 5, 10, 20, 30, 50, 75, 100)
- All modules added to all 6 language stores with EN fallback
2026-01-16 14:17:13 +01:00
a7e765cb80 fix: add DROP statements to supabase-setup.sql for clean reinstalls
TODO: Configure OAuth providers in Supabase dashboard:
- Google: Add Client ID and Secret from Google Cloud Console
- GitHub: Add Client ID and Secret from GitHub Developer Settings
- Set redirect URLs in Authentication → URL Configuration
2026-01-16 14:06:14 +01:00
d2fbe0e085 feat: implement milestone-based progress system and activate new lessons
Progress System:
- Replace percentage-based progress with milestone markers (1, 5, 10, 20, 30, 50, 75, 100)
- Add visual milestone indicators with reached/current/next states
- Add celebration animation when milestones are reached
- Update progress bar to show progress toward next milestone
- Add progressTextMilestone i18n key for all 6 languages

New Lessons Activated:
- HTML Dialog (native modal dialogs)
- HTML Progress & Meter (indicator elements)
- HTML Fieldset (form grouping)
- HTML Datalist (autocomplete inputs)

This adds 10 new lessons across all 6 languages, bringing total from ~66 to ~76.
2026-01-16 13:56:29 +01:00
b051974957 docs: add comprehensive roadmap for lessons and milestone system
- Analyze MDN HTML/CSS documentation for new lesson ideas
- Design milestone-based progress system (1, 5, 10, 20, 30, 50, 75, 100)
- Document 13 inactive lesson files ready to activate
- Plan 34 new lessons to reach 100 total
- Include technical implementation notes
2026-01-16 13:47:42 +01:00
68407fe12b feat: add authentication, cloud sync, and GDPR compliance
Authentication & Cloud Sync:
- Add Supabase integration for auth (email/password, Google, GitHub OAuth)
- Add cloud progress sync for logged-in users
- Add account deletion feature with confirmation dialog
- Auth is optional - anonymous users can still use localStorage

UI Improvements:
- Add dark-themed account section in sidebar
- Show user email in header when logged in
- Add signup success feedback message
- Update landing page: remove cloud sync from Coming Soon, add Code Challenges
- Update benefit text to mention optional cloud sync

GDPR Compliance:
- Add Privacy Policy dialog with full GDPR-compliant content
- Add Imprint dialog with legal contact information
- Add footer links for Privacy and Imprint
- All legal content translated to 6 languages (en, de, pl, es, ar, uk)

Files added:
- src/supabase.js - Supabase client with auth and progress sync helpers
- src/auth.js - Authentication logic and form handlers
- supabase-setup.sql - Database schema and RLS policies
2026-01-16 12:37:22 +01:00
ea57ce6d28 fix: change tracking logs to console.debug 2026-01-16 11:19:14 +01:00
0fb352c027 fix: add tracking debug logs for success and blocked states 2026-01-16 11:16:49 +01:00
9f9dc73b11 fix: add console.log to debug newsletter tracking 2026-01-16 11:16:26 +01:00
0748b23d4c feat: add newsletter signup with email field and Umami tracking
- Add email input field to newsletter signup form
- Add disclaimer about max frequency and unsubscribe option
- Add newsletter translations for all 6 languages (en, de, pl, es, ar, uk)
- Update hero highlight to "Crispy Code"
- Update CTA button to "Let's get crispy!"
- Add Umami tracking for newsletter submissions
- Style newsletter form without white background
2026-01-16 11:06:42 +01:00
1b3c2b42dc feat: add coming soon section and device notice to landing page
- Add "Coming Soon" section with Cloud Sync, Achievements, JavaScript, Frameworks
- Add device notice recommending desktop/tablet for best experience
- Add translations for all 6 languages (en, de, pl, es, ar, uk)
- Add CSS styling with responsive grid layout

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-01-16 10:46:12 +01:00
efbadbfb76 fix: hide expected result button in playground mode
🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-01-16 05:06:17 +01:00
547840c3fd fix: center game controls in playground mode
🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-01-16 05:05:29 +01:00
55379c14f0 fix: remove duplicate isPlayground declaration
🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-01-16 05:03:55 +01:00
c59736c0e2 fix: hide lesson counter in playground mode
- Hide level indicator (1/1) in playground mode
- Show only module title in header pill for playground
- Keep nav buttons hidden in playground

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-01-16 05:02:40 +01:00
c0e1dab0d9 fix: hide prev/next buttons in playground mode
🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-01-16 05:01:15 +01:00
469f6a81a5 refactor: make CSS purple the default section color
- Update :root section colors to CSS purple (#9163b8)
- Remove redundant [data-section="css"] overrides
- CSS section now uses fallback colors automatically

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-01-16 05:00:00 +01:00
cb87adb249 fix: update all section color references to balanced colors
Update all rgba and hex values for hover/active states:
- CSS: rgba(145, 99, 184), #724a95
- HTML: rgba(212, 90, 160), #b24485
- Tailwind: rgba(26, 175, 184), #0d8f96

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-01-16 04:59:01 +01:00
96b71079d8 style: balance section colors between muted and vibrant
Find middle ground between old muted and badge colors:
- CSS: #9163b8 (balanced purple)
- HTML: #d45aa0 (balanced pink)
- Tailwind: #1aafb8 (balanced teal)

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-01-16 04:57:20 +01:00
8513189efe style: update section colors to match completed badge
Use colors from the completed badge gradient:
- CSS: #9b59b6 (purple)
- HTML: #e040fb (pink/magenta)
- Tailwind: #00bcd4 (cyan)

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-01-16 04:56:32 +01:00
e65fdb0abc feat: add interactive UI component templates
Add 10 new templates showcasing native HTML features:
- Accordion FAQ: using <details> and <summary>
- Form Validation: HTML5 validation attributes
- Toggle Switch: styled checkbox as iOS toggle
- CSS Tabs: pure CSS tabs with radio buttons
- Modal Dialog: native <dialog> element
- Tooltip: CSS-only hover tooltips
- Progress Steps: checkout wizard indicator
- Dropdown Menu: CSS :hover/:focus-within
- Star Rating: interactive CSS-only stars
- Search Box: styled search with suggestions

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-01-16 04:52:33 +01:00
30635a9e69 fix: footer links on all pages and scroll behavior
- Render footer lesson links in initializeModules() for all pages
- Fix scroll to top using requestAnimationFrame for proper timing
- Move dice button to left of editor tools

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-01-16 04:50:47 +01:00
d408c49e45 feat: add real Unsplash images to playground templates
Replace CSS gradient placeholders with actual photos:
- Card Component: mountain landscape
- Profile Card: woman portrait
- Social Post: avatar + nature photo
- Story Highlights: 5 different portrait photos
- Comment Section: 2 avatar photos
- Bio Section: woman portrait with gradient ring
- Status Update: man portrait

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-01-16 04:50:29 +01:00
817dc09a58 feat: add social media playground templates and use EUR currency
- Add 10 new social media templates: Social Post, Story Highlights,
  Like Button, Comment Section, Notification Badge, Emoji Reactions,
  Bio Section, Status Update, Chat Bubble
- Change pricing from $ to € (€9, €29, €99)
- Fix Story Highlights with shorter names for better rendering
- Fix Bio Section avatar overflow issue

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-01-16 04:48:03 +01:00
4c56342cb7 feat: add comprehensive analytics tracking and enhance practice links
- Add tracking for: coming_soon_click, external_link, header_nav_click,
  footer_link, practice_link, expected_toggle
- Enhance practice links (.ref-see-also) with gradient background and
  button-like styling to encourage learning
- Simplify reference nav (remove sticky positioning)
2026-01-16 04:38:13 +01:00
0a03d51e63 feat: complete section color coding with logo, hints, editor themes, and footers
- Add section-specific CodeMirror syntax highlighting (purple selectors for CSS)
- Logo now uses section colors (CSS purple as default, changes per section)
- Add section color coding for hints
- Add full footer to section and reference pages
- Fix nav highlight updates for sidebar and prev/next navigation
- Change welcome module mode to CSS for purple theme on first lesson
- Rebrand "Code Crispies" to "CODE CRISPIES" across all translations
- Fix scroll to top on section page navigation
- Change HTML section color to raspberry (#c75b7a)
2026-01-16 04:32:55 +01:00
53 changed files with 9660 additions and 401 deletions

View File

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

15
.gitignore vendored
View File

@@ -3,6 +3,19 @@
node_modules
dist
coverage
.env
.env.local
# Claude Code local settings (user-specific)
.claude/settings.local.json
.claude/settings.local.json
.claude_settings.json
# Auto-Claude
.auto-claude
.worktrees
# Wave ephemeral data
.wave/workspaces
.wave/traces
.wave/artifacts
.wave/output

297
docs/ROADMAP.md Normal file
View File

@@ -0,0 +1,297 @@
# Code Crispies Roadmap
## Current State (Updated)
**Total Active Lessons:** 101 (excluding welcome, goodbye, playground)
**Target:** 100 lessons for milestone system ✅ ACHIEVED
### Current Module Breakdown
| Module | Lessons | Category | Status |
|--------|---------|----------|--------|
| Basic Selectors | 10 | CSS | ✅ |
| Colors | 4 | CSS | ✅ |
| **Gradients** | 3 | CSS | ✅ NEW |
| Typography | 6 | CSS | ✅ +2 |
| Box Model | 8 | CSS | ✅ |
| Flexbox | 6 | CSS | ✅ |
| Grid | 6 | CSS | ✅ |
| **Positioning** | 4 | CSS | ✅ NEW |
| Units & Variables | 4 | CSS | ✅ |
| Responsive | 4 | CSS | ✅ |
| Transitions & Animations | 4 | CSS | ✅ |
| **Filters** | 4 | CSS | ✅ NEW |
| **Pseudo-elements** | 4 | CSS | ✅ NEW |
| HTML Elements | 2 | HTML | ✅ |
| **Semantic HTML** | 3 | HTML | ✅ NEW |
| Figure | 3 | HTML | ✅ |
| SVG | 3 | HTML | ✅ |
| Details/Summary | 3 | HTML | ✅ |
| Dialog | 2 | HTML | ✅ |
| Progress/Meter | 3 | HTML | ✅ |
| Forms Basic | 3 | HTML | ✅ |
| Forms Validation | 1 | HTML | ✅ |
| Fieldset | 3 | HTML | ✅ |
| Datalist | 2 | HTML | ✅ |
| Tables | 3 | HTML | ✅ +2 |
| **Total** | **101** | | ✅ |
---
## Phase 1: Milestone Progress System ✅ COMPLETED
### Design
Replace percentage-based progress with milestone markers:
```
[1] [5] [10] [20] [30] [50] [75] [100]
● ● ◐ ○ ○ ○ ○ ○
```
**Milestones:**
- 1 lesson - First Step
- 5 lessons - Getting Started
- 10 lessons - Rookie
- 20 lessons - Learner
- 30 lessons - Intermediate
- 50 lessons - Halfway Hero
- 75 lessons - Advanced
- 100 lessons - Master
### Implementation ✅
1. **Update `LessonEngine.getProgressStats()`**
- Added `currentMilestone` and `nextMilestone` fields
- Added `milestonesReached: number[]`
- Added `progressToNext` percentage
2. **Update Progress UI**
- Added milestone dots with visual states (reached, current, next)
- Animate milestone completion
- Show current milestone badge
3. **Add Milestone Celebration**
- Confetti/animation on reaching milestones
- Achievement unlocks in sidebar
---
## Phase 2: New Lessons (34 needed to reach 100)
### Priority 1: Expand Existing Modules (+15 lessons)
#### CSS Colors (+3)
- Gradients (linear-gradient)
- Color functions (hsl, rgb)
- Opacity and transparency
#### Typography (+3)
- Web fonts (@font-face)
- Text shadows
- Letter/word spacing
#### Responsive (+3)
- Container queries
- Aspect ratio
- Clamp() for fluid typography
#### Transitions & Animations (+3)
- Keyframe animations
- Animation timing functions
- Transform origin
#### Tables (+3)
- Table styling (borders, spacing)
- Responsive tables
- Table accessibility
### Priority 2: New CSS Modules (+12 lessons)
#### Filters & Effects (4 lessons)
- CSS filters (blur, brightness, contrast)
- Backdrop filters
- Mix-blend-mode
- Box shadows advanced
#### Positioning (4 lessons)
- Relative positioning
- Absolute positioning
- Fixed/sticky positioning
- Z-index stacking
#### Pseudo-elements (4 lessons)
- ::before and ::after
- ::first-letter and ::first-line
- ::marker for lists
- Content property
### Priority 3: New HTML Modules (+7 lessons)
#### Semantic Structure (3 lessons)
- Article vs Section
- Header/Footer/Main
- Nav and Aside
#### Media Elements (2 lessons)
- Picture element (responsive images)
- Audio/Video basics
#### Accessibility (2 lessons)
- ARIA labels
- Skip links
- Focus management
---
## MDN Topics Reference
### CSS Topics from MDN (prioritized for interactive lessons)
**Layout Systems:**
- [x] Flexbox (covered)
- [x] Grid (covered)
- [ ] Multi-column layout
- [ ] Positioned layout (z-index, stacking)
**Visual Effects:**
- [x] Colors (partially covered)
- [ ] Filters (blur, brightness, contrast, drop-shadow)
- [ ] Blend modes (mix-blend-mode, background-blend-mode)
- [ ] Masking and clipping
- [ ] Shapes (shape-outside)
**Typography:**
- [x] Basic text (covered)
- [ ] Web fonts (@font-face)
- [ ] Variable fonts
- [ ] Text decoration advanced
**Animations:**
- [x] Transitions (covered)
- [ ] Keyframe animations
- [ ] Scroll-driven animations (experimental)
- [ ] View transitions
**Advanced:**
- [x] Custom properties (covered in units-variables)
- [ ] Container queries
- [ ] Anchor positioning (new)
- [ ] Logical properties (for RTL support)
### HTML Topics from MDN
**Structural:**
- [x] Basic elements (covered)
- [x] Figure/figcaption (covered)
- [ ] Article vs section semantics
- [ ] Template element
**Interactive:**
- [x] Details/Summary (covered)
- [x] Dialog (have JSON, not active)
- [ ] Datalist (have JSON, not active)
- [ ] Progress/Meter (have JSON, not active)
**Forms:**
- [x] Basic forms (covered)
- [x] Validation (covered)
- [x] Fieldset (have JSON, not active)
- [ ] Input types exploration
**Media:**
- [x] SVG basics (covered)
- [ ] Picture element
- [ ] srcset and sizes
- [ ] Audio/Video
---
## Inactive Lesson Files (Ready to Activate)
These lesson files exist but aren't in the active module list:
| File | Lessons | Topic |
|------|---------|-------|
| 24-html-progress-meter.json | 3 | Progress/Meter elements |
| 25-html-datalist.json | 2 | Datalist for autocomplete |
| 27-html-dialog.json | 2 | Native dialog element |
| 28-html-forms-fieldset.json | 3 | Fieldset/legend grouping |
| 31-html-marquee.json | 3 | Marquee (deprecated but fun) |
| **Total** | **13** | |
**Quick Win:** Activating these adds 13 lessons immediately → 79 total
---
## Implementation Order
### Week 1: Foundation
1. Design milestone UI component
2. Implement milestone progress system
3. Add milestone celebrations
### Week 2: Quick Wins
4. Activate 5 inactive HTML modules (+13 lessons)
5. Test and fix translations
### Week 3-4: New Content
6. Create Filters & Effects module (+4)
7. Create Positioning module (+4)
8. Expand existing modules (+7)
### Final Polish
9. Reach 100 lessons milestone
10. Add milestone achievements to sidebar
11. Update landing page messaging
---
## Technical Notes
### Milestone Data Structure
```js
const MILESTONES = [1, 5, 10, 20, 30, 50, 75, 100];
function getMilestoneProgress(completed) {
const reached = MILESTONES.filter(m => completed >= m);
const current = reached[reached.length - 1] || 0;
const next = MILESTONES.find(m => m > completed) || 100;
return {
current,
next,
reached,
percentToNext: ((completed - current) / (next - current)) * 100
};
}
```
### Progress Display Update
```html
<div class="milestone-progress">
<div class="milestones">
<span class="milestone reached" data-value="1">1</span>
<span class="milestone reached" data-value="5">5</span>
<span class="milestone current" data-value="10">10</span>
<span class="milestone" data-value="20">20</span>
<!-- ... -->
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: 35%"></div>
</div>
<span class="progress-label">12 of 100 lessons</span>
</div>
```
---
## Success Metrics
- [ ] 100 total lessons
- [ ] Milestone system implemented
- [ ] All 6 languages have translations
- [ ] Achievement celebrations working
- [ ] Mobile responsive milestone UI

61
flake.lock generated Normal file
View File

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

View File

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

View File

@@ -3,7 +3,7 @@
"id": "welcome",
"title": "Welcome",
"description": "Get started with Code Crispies",
"mode": "html",
"mode": "css",
"difficulty": "beginner",
"excludeFromProgress": true,
"lessons": [

View File

@@ -98,6 +98,53 @@
"message": "Set letter-spacing to <kbd>1px</kbd>"
}
]
},
{
"id": "text-decoration",
"title": "Text Decoration",
"description": "The <kbd>text-decoration</kbd> property adds lines to text. Common values:<br><br>• <kbd>underline</kbd> — line below text<br>• <kbd>line-through</kbd> — strikethrough<br>• <kbd>none</kbd> — removes decoration (useful for links)<br><br>You can also style decorations with <kbd>text-decoration-color</kbd> and <kbd>text-decoration-style</kbd>.",
"task": "Show the old price with a strikethrough. Add <kbd>text-decoration: line-through</kbd>.",
"previewHTML": "<div class=\"price-box\"><span class=\"old-price\">$49.99</span><span class=\"new-price\">$29.99</span></div>",
"previewBaseCSS": "body { font-family: system-ui; padding: 1rem; } .price-box { display: flex; gap: 1rem; align-items: center; } .old-price { color: #999; font-size: 1rem; } .new-price { color: coral; font-size: 1.5rem; font-weight: bold; }",
"sandboxCSS": "",
"codePrefix": ".old-price {\n ",
"initialCode": "",
"codeSuffix": "\n}",
"previewContainer": "preview-area",
"solution": "text-decoration: line-through;",
"validations": [
{
"type": "property_value",
"value": { "property": "text-decoration", "expected": "line-through" },
"message": "Set text-decoration to <kbd>line-through</kbd>"
}
]
},
{
"id": "text-shadow",
"title": "Text Shadow",
"description": "The <kbd>text-shadow</kbd> property adds shadow effects to text. The syntax is:<br><br><pre>text-shadow: x-offset y-offset blur color;</pre><br>Example: <kbd>text-shadow: 2px 2px 4px gray</kbd> creates a soft shadow offset down and right.",
"task": "Add depth to the heading with <kbd>text-shadow: 2px 2px 4px gray</kbd>.",
"previewHTML": "<h1 class=\"hero-title\">Welcome</h1>",
"previewBaseCSS": "body { font-family: system-ui; padding: 2rem; background: linear-gradient(135deg, #667eea, #764ba2); } .hero-title { margin: 0; font-size: 3rem; color: white; text-align: center; }",
"sandboxCSS": "",
"codePrefix": ".hero-title {\n ",
"initialCode": "",
"codeSuffix": "\n}",
"previewContainer": "preview-area",
"solution": "text-shadow: 2px 2px 4px gray;",
"validations": [
{
"type": "contains",
"value": "text-shadow",
"message": "Use <kbd>text-shadow</kbd> property"
},
{
"type": "contains",
"value": "2px 2px",
"message": "Set offset to <kbd>2px 2px</kbd>"
}
]
}
]
}

92
lessons/09-gradients.json Normal file
View File

@@ -0,0 +1,92 @@
{
"$schema": "../schemas/code-crispies-module-schema.json",
"id": "css-gradients",
"title": "CSS Gradients",
"description": "Create smooth color transitions with CSS gradients.",
"difficulty": "intermediate",
"lessons": [
{
"id": "gradients-1",
"title": "Linear Gradient",
"description": "Gradients create smooth transitions between colors. The <kbd>linear-gradient()</kbd> function creates a gradient along a straight line.<br><br><strong>Basic syntax:</strong><br><pre>background: linear-gradient(color1, color2);</pre><br>By default, gradients flow from top to bottom.",
"task": "Add a gradient background from <kbd>coral</kbd> to <kbd>gold</kbd>.",
"previewHTML": "<div class=\"card\"><h3>Summer Sale</h3><p>Up to 50% off</p></div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .card { padding: 2rem; border-radius: 12px; color: white; text-shadow: 0 1px 2px rgba(0,0,0,0.2); } .card h3 { margin: 0 0 8px; font-size: 1.5rem; } .card p { margin: 0; opacity: 0.9; }",
"sandboxCSS": "",
"codePrefix": ".card {\n ",
"initialCode": "",
"codeSuffix": "\n}",
"solution": "background: linear-gradient(coral, gold);",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "linear-gradient",
"message": "Use <kbd>linear-gradient()</kbd>"
},
{
"type": "contains",
"value": "coral",
"message": "Include <kbd>coral</kbd> as the first color"
},
{
"type": "contains",
"value": "gold",
"message": "Include <kbd>gold</kbd> as the second color"
}
]
},
{
"id": "gradients-2",
"title": "Gradient Direction",
"description": "Control the gradient direction by adding an angle or keyword before the colors.<br><br><strong>Keywords:</strong> <kbd>to right</kbd>, <kbd>to left</kbd>, <kbd>to bottom right</kbd><br><strong>Angles:</strong> <kbd>45deg</kbd>, <kbd>90deg</kbd>, <kbd>180deg</kbd><br><br><pre>background: linear-gradient(to right, blue, purple);</pre>",
"task": "Make the gradient flow from left to right using <kbd>to right</kbd>.",
"previewHTML": "<button class=\"btn\">Get Started</button>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 2rem; } .btn { padding: 1rem 2rem; border: none; border-radius: 8px; font-size: 1rem; font-weight: 600; color: white; cursor: pointer; }",
"sandboxCSS": "",
"codePrefix": ".btn {\n background: linear-gradient(",
"initialCode": "",
"codeSuffix": ", steelblue, mediumseagreen);\n}",
"solution": "to right",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "to right",
"message": "Add <kbd>to right</kbd> to set the direction"
}
]
},
{
"id": "gradients-3",
"title": "Radial Gradient",
"description": "The <kbd>radial-gradient()</kbd> function creates a gradient that radiates from a center point outward in a circular or elliptical pattern.<br><br><pre>background: radial-gradient(circle, white, steelblue);</pre><br>Add <kbd>circle</kbd> for a perfect circular gradient.",
"task": "Create a radial gradient from <kbd>white</kbd> to <kbd>steelblue</kbd>.",
"previewHTML": "<div class=\"orb\"></div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 2rem; display: flex; justify-content: center; } .orb { width: 150px; height: 150px; border-radius: 50%; box-shadow: 0 8px 32px rgba(70, 130, 180, 0.4); }",
"sandboxCSS": "",
"codePrefix": ".orb {\n ",
"initialCode": "",
"codeSuffix": "\n}",
"solution": "background: radial-gradient(circle, white, steelblue);",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "radial-gradient",
"message": "Use <kbd>radial-gradient()</kbd>"
},
{
"type": "contains",
"value": "white",
"message": "Start with <kbd>white</kbd>"
},
{
"type": "contains",
"value": "steelblue",
"message": "End with <kbd>steelblue</kbd>"
}
]
}
]
}

108
lessons/11-filters.json Normal file
View File

@@ -0,0 +1,108 @@
{
"$schema": "../schemas/code-crispies-module-schema.json",
"id": "css-filters",
"title": "CSS Filters",
"description": "Apply visual effects like blur, brightness, and shadows with CSS filters.",
"difficulty": "intermediate",
"lessons": [
{
"id": "filters-1",
"title": "Blur Filter",
"description": "The <kbd>filter</kbd> property applies visual effects to elements. The <kbd>blur()</kbd> function creates a Gaussian blur effect.<br><br><pre>filter: blur(4px);</pre><br>Higher values create more blur. This is great for backgrounds or creating depth.",
"task": "Blur the background image using <kbd>filter: blur(4px)</kbd>.",
"previewHTML": "<div class=\"bg\"></div><div class=\"content\"><h2>Welcome</h2></div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; margin: 0; height: 200px; position: relative; overflow: hidden; } .bg { position: absolute; inset: 0; background: linear-gradient(45deg, coral, gold, steelblue); } .content { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; height: 100%; } .content h2 { color: white; text-shadow: 0 2px 8px rgba(0,0,0,0.3); margin: 0; }",
"sandboxCSS": "",
"codePrefix": ".bg {\n ",
"initialCode": "",
"codeSuffix": "\n}",
"solution": "filter: blur(4px);",
"previewContainer": "preview-area",
"validations": [
{
"type": "property_value",
"value": { "property": "filter", "expected": "blur(4px)" },
"message": "Set <kbd>filter: blur(4px)</kbd>"
}
]
},
{
"id": "filters-2",
"title": "Grayscale Filter",
"description": "The <kbd>grayscale()</kbd> function removes color from an element. Use values from <kbd>0%</kbd> (full color) to <kbd>100%</kbd> (fully grayscale).<br><br><pre>filter: grayscale(100%);</pre><br>Great for hover effects or disabled states.",
"task": "Make the image grayscale with <kbd>filter: grayscale(100%)</kbd>.",
"previewHTML": "<div class=\"photo\"></div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .photo { width: 200px; height: 150px; background: linear-gradient(135deg, coral 0%, gold 50%, steelblue 100%); border-radius: 8px; }",
"sandboxCSS": "",
"codePrefix": ".photo {\n ",
"initialCode": "",
"codeSuffix": "\n}",
"solution": "filter: grayscale(100%);",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "grayscale",
"message": "Use <kbd>grayscale()</kbd> filter"
},
{
"type": "contains",
"value": "100%",
"message": "Set to <kbd>100%</kbd> for full grayscale"
}
]
},
{
"id": "filters-3",
"title": "Brightness Filter",
"description": "The <kbd>brightness()</kbd> function adjusts how bright an element appears. Values below <kbd>100%</kbd> darken, above <kbd>100%</kbd> brighten.<br><br><pre>filter: brightness(150%);</pre>",
"task": "Brighten the card with <kbd>filter: brightness(120%)</kbd>.",
"previewHTML": "<div class=\"card\"><span>Featured</span></div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #1a1a2e; } .card { padding: 2rem; background: linear-gradient(135deg, #4a4a6a, #2a2a4a); border-radius: 12px; text-align: center; } .card span { color: gold; font-weight: 600; text-transform: uppercase; letter-spacing: 2px; }",
"sandboxCSS": "",
"codePrefix": ".card {\n ",
"initialCode": "",
"codeSuffix": "\n}",
"solution": "filter: brightness(120%);",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "brightness",
"message": "Use <kbd>brightness()</kbd> filter"
},
{
"type": "contains",
"value": "120%",
"message": "Set to <kbd>120%</kbd>"
}
]
},
{
"id": "filters-4",
"title": "Drop Shadow",
"description": "The <kbd>drop-shadow()</kbd> filter creates a shadow that follows the shape of the element, including transparency. Unlike <kbd>box-shadow</kbd>, it works on images with transparent backgrounds.<br><br><pre>filter: drop-shadow(2px 4px 6px black);</pre>",
"task": "Add a drop shadow with <kbd>filter: drop-shadow(4px 4px 8px gray)</kbd>.",
"previewHTML": "<div class=\"icon\">★</div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 2rem; display: flex; justify-content: center; } .icon { font-size: 4rem; color: gold; }",
"sandboxCSS": "",
"codePrefix": ".icon {\n ",
"initialCode": "",
"codeSuffix": "\n}",
"solution": "filter: drop-shadow(4px 4px 8px gray);",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "drop-shadow",
"message": "Use <kbd>drop-shadow()</kbd> filter"
},
{
"type": "contains",
"value": "4px 4px 8px",
"message": "Set shadow offset and blur"
}
]
}
]
}

View File

@@ -0,0 +1,98 @@
{
"$schema": "../schemas/code-crispies-module-schema.json",
"id": "css-positioning",
"title": "CSS Positioning",
"description": "Control element placement with CSS positioning properties.",
"difficulty": "intermediate",
"lessons": [
{
"id": "position-1",
"title": "Relative Position",
"description": "The <kbd>position</kbd> property controls how elements are placed. <kbd>relative</kbd> keeps the element in normal flow but allows you to offset it with <kbd>top</kbd>, <kbd>right</kbd>, <kbd>bottom</kbd>, <kbd>left</kbd>.<br><br><pre>.box {\n position: relative;\n top: 10px;\n}</pre>",
"task": "Make the badge position relative so we can offset it.",
"previewHTML": "<div class=\"card\"><span class=\"badge\">NEW</span><h3>Product</h3></div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .card { padding: 1rem; background: white; border: 2px solid #eee; border-radius: 8px; } .card h3 { margin: 0; } .badge { display: inline-block; padding: 2px 8px; background: coral; color: white; font-size: 0.7rem; font-weight: bold; border-radius: 4px; }",
"sandboxCSS": "",
"codePrefix": ".badge {\n ",
"initialCode": "",
"codeSuffix": "\n}",
"solution": "position: relative;",
"previewContainer": "preview-area",
"validations": [
{
"type": "property_value",
"value": { "property": "position", "expected": "relative" },
"message": "Set <kbd>position: relative</kbd>"
}
]
},
{
"id": "position-2",
"title": "Offset Properties",
"description": "With <kbd>position: relative</kbd>, use offset properties to nudge the element from its original position:<br><br><kbd>top</kbd> - pushes down from top<br><kbd>left</kbd> - pushes right from left<br><br>Negative values move in the opposite direction.",
"task": "Move the badge up with <kbd>top: -8px</kbd>.",
"previewHTML": "<div class=\"card\"><span class=\"badge\">NEW</span><h3>Product</h3></div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .card { padding: 1rem; background: white; border: 2px solid #eee; border-radius: 8px; } .card h3 { margin: 0; } .badge { display: inline-block; padding: 2px 8px; background: coral; color: white; font-size: 0.7rem; font-weight: bold; border-radius: 4px; position: relative; }",
"sandboxCSS": "",
"codePrefix": ".badge {\n ",
"initialCode": "",
"codeSuffix": "\n}",
"solution": "top: -8px;",
"previewContainer": "preview-area",
"validations": [
{
"type": "property_value",
"value": { "property": "top", "expected": "-8px" },
"message": "Set <kbd>top: -8px</kbd>"
}
]
},
{
"id": "position-3",
"title": "Absolute Position",
"description": "<kbd>position: absolute</kbd> removes the element from normal flow and positions it relative to its nearest positioned ancestor (or the viewport if none exists).<br><br>Always set a parent to <kbd>position: relative</kbd> to contain absolute children.",
"task": "Position the close button absolutely.",
"previewHTML": "<div class=\"modal\"><button class=\"close\">×</button><h3>Modal</h3><p>Content here</p></div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .modal { position: relative; padding: 2rem; background: white; border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,0.15); max-width: 250px; } .modal h3 { margin: 0 0 8px; } .modal p { margin: 0; color: #666; } .close { width: 32px; height: 32px; border: none; background: #f5f5f5; border-radius: 50%; font-size: 1.2rem; cursor: pointer; }",
"sandboxCSS": "",
"codePrefix": ".close {\n ",
"initialCode": "",
"codeSuffix": "\n}",
"solution": "position: absolute;",
"previewContainer": "preview-area",
"validations": [
{
"type": "property_value",
"value": { "property": "position", "expected": "absolute" },
"message": "Set <kbd>position: absolute</kbd>"
}
]
},
{
"id": "position-4",
"title": "Placing Absolute Elements",
"description": "Combine <kbd>position: absolute</kbd> with offset properties to place elements precisely.<br><br><pre>.close {\n position: absolute;\n top: 8px;\n right: 8px;\n}</pre>",
"task": "Move the close button to the top right corner with <kbd>top: 8px</kbd> and <kbd>right: 8px</kbd>.",
"previewHTML": "<div class=\"modal\"><button class=\"close\">×</button><h3>Modal</h3><p>Content here</p></div>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .modal { position: relative; padding: 2rem; background: white; border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,0.15); max-width: 250px; } .modal h3 { margin: 0 0 8px; } .modal p { margin: 0; color: #666; } .close { position: absolute; width: 32px; height: 32px; border: none; background: #f5f5f5; border-radius: 50%; font-size: 1.2rem; cursor: pointer; }",
"sandboxCSS": "",
"codePrefix": ".close {\n ",
"initialCode": "",
"codeSuffix": "\n}",
"solution": "top: 8px;\n right: 8px;",
"previewContainer": "preview-area",
"validations": [
{
"type": "property_value",
"value": { "property": "top", "expected": "8px" },
"message": "Set <kbd>top: 8px</kbd>"
},
{
"type": "property_value",
"value": { "property": "right", "expected": "8px" },
"message": "Set <kbd>right: 8px</kbd>"
}
]
}
]
}

View File

@@ -0,0 +1,113 @@
{
"$schema": "../schemas/code-crispies-module-schema.json",
"id": "css-pseudo-elements",
"title": "CSS Pseudo-elements",
"description": "Create decorative elements and style specific parts of content with pseudo-elements.",
"difficulty": "intermediate",
"lessons": [
{
"id": "pseudo-1",
"title": "The ::before Element",
"description": "Pseudo-elements let you style specific parts of an element. <kbd>::before</kbd> creates a virtual element as the first child.<br><br>It requires the <kbd>content</kbd> property to display anything (even if empty).<br><br><pre>.item::before {\n content: \"→ \";\n}</pre>",
"task": "Add a bullet before each list item using <kbd>::before</kbd> with <kbd>content: \"• \"</kbd>.",
"previewHTML": "<ul class=\"list\"><li>First item</li><li>Second item</li><li>Third item</li></ul>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .list { list-style: none; padding: 0; margin: 0; } .list li { padding: 8px 0; }",
"sandboxCSS": "",
"codePrefix": ".list li::before {\n ",
"initialCode": "",
"codeSuffix": "\n}",
"solution": "content: \"• \";",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "content",
"message": "Use the <kbd>content</kbd> property"
},
{
"type": "contains",
"value": "•",
"message": "Add a bullet character <kbd>•</kbd>"
}
]
},
{
"id": "pseudo-2",
"title": "Styling ::before",
"description": "Pseudo-elements can be styled like any element. Add color, size, margins, and more.<br><br><pre>.item::before {\n content: \"★\";\n color: gold;\n margin-right: 8px;\n}</pre>",
"task": "Style the bullet with <kbd>color: coral</kbd>.",
"previewHTML": "<ul class=\"list\"><li>First item</li><li>Second item</li><li>Third item</li></ul>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .list { list-style: none; padding: 0; margin: 0; } .list li { padding: 8px 0; } .list li::before { content: \"• \"; }",
"sandboxCSS": "",
"codePrefix": ".list li::before {\n ",
"initialCode": "",
"codeSuffix": "\n}",
"solution": "color: coral;",
"previewContainer": "preview-area",
"validations": [
{
"type": "property_value",
"value": { "property": "color", "expected": "coral" },
"message": "Set <kbd>color: coral</kbd>"
}
]
},
{
"id": "pseudo-3",
"title": "The ::after Element",
"description": "<kbd>::after</kbd> works like <kbd>::before</kbd> but inserts content as the last child. Common uses include badges, icons, or decorative elements.<br><br><pre>.new::after {\n content: \" ✓\";\n color: green;\n}</pre>",
"task": "Add a checkmark after completed items with <kbd>content: \" ✓\"</kbd>.",
"previewHTML": "<ul class=\"list\"><li class=\"done\">Buy groceries</li><li class=\"done\">Walk the dog</li><li>Read a book</li></ul>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .list { list-style: none; padding: 0; margin: 0; } .list li { padding: 8px 0; }",
"sandboxCSS": "",
"codePrefix": ".done::after {\n ",
"initialCode": "",
"codeSuffix": "\n}",
"solution": "content: \" ✓\";",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "content",
"message": "Use the <kbd>content</kbd> property"
},
{
"type": "contains",
"value": "✓",
"message": "Add a checkmark <kbd>✓</kbd>"
}
]
},
{
"id": "pseudo-4",
"title": "Decorative Lines",
"description": "Pseudo-elements with <kbd>content: \"\"</kbd> can create decorative shapes when combined with width, height, and background.<br><br><pre>.title::after {\n content: \"\";\n display: block;\n width: 50px;\n height: 3px;\n background: coral;\n}</pre>",
"task": "Create an underline decoration with <kbd>width: 40px</kbd>, <kbd>height: 3px</kbd>, and <kbd>background: steelblue</kbd>.",
"previewHTML": "<h2 class=\"title\">About Us</h2><p>We build great things.</p>",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .title { margin: 0 0 1rem; } .title::after { content: \"\"; display: block; margin-top: 8px; } p { margin: 0; color: #666; }",
"sandboxCSS": "",
"codePrefix": ".title::after {\n ",
"initialCode": "",
"codeSuffix": "\n}",
"solution": "width: 40px;\n height: 3px;\n background: steelblue;",
"previewContainer": "preview-area",
"validations": [
{
"type": "property_value",
"value": { "property": "width", "expected": "40px" },
"message": "Set <kbd>width: 40px</kbd>"
},
{
"type": "property_value",
"value": { "property": "height", "expected": "3px" },
"message": "Set <kbd>height: 3px</kbd>"
},
{
"type": "property_value",
"value": { "property": "background", "expected": "steelblue" },
"message": "Set <kbd>background: steelblue</kbd>"
}
]
}
]
}

View File

@@ -39,6 +39,54 @@
"message": "Add 3 rows (1 header + 2 data rows)"
}
]
},
{
"id": "table-sections",
"title": "Table Sections",
"description": "Semantic table sections improve accessibility and allow for separate styling:<br><br>• <kbd>&lt;thead&gt;</kbd> — header section<br>• <kbd>&lt;tbody&gt;</kbd> — main content<br>• <kbd>&lt;tfoot&gt;</kbd> — footer (totals, summaries)",
"task": "Wrap the header row in <kbd>&lt;thead&gt;</kbd> and data rows in <kbd>&lt;tbody&gt;</kbd>.",
"previewHTML": "",
"previewBaseCSS": "body { font-family: system-ui; padding: 20px; } table { border-collapse: collapse; width: 100%; max-width: 350px; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); } th, td { padding: 12px 16px; text-align: left; } thead { background: steelblue; color: white; } tbody tr:nth-child(even) { background: #f8f9fa; }",
"sandboxCSS": "",
"initialCode": "<table>\n <tr>\n <th>Name</th>\n <th>Score</th>\n </tr>\n <tr>\n <td>Alice</td>\n <td>95</td>\n </tr>\n <tr>\n <td>Bob</td>\n <td>87</td>\n </tr>\n</table>",
"solution": "<table>\n <thead>\n <tr>\n <th>Name</th>\n <th>Score</th>\n </tr>\n </thead>\n <tbody>\n <tr>\n <td>Alice</td>\n <td>95</td>\n </tr>\n <tr>\n <td>Bob</td>\n <td>87</td>\n </tr>\n </tbody>\n</table>",
"previewContainer": "preview-area",
"validations": [
{
"type": "element_exists",
"value": "thead",
"message": "Add a <kbd>&lt;thead&gt;</kbd> section for the header"
},
{
"type": "element_exists",
"value": "tbody",
"message": "Add a <kbd>&lt;tbody&gt;</kbd> section for the data"
}
]
},
{
"id": "table-colspan",
"title": "Spanning Columns",
"description": "The <kbd>colspan</kbd> attribute lets a cell span multiple columns. This is useful for headers that group multiple columns or footer totals.<br><br><pre>&lt;td colspan=\"2\"&gt;...&lt;/td&gt;</pre>",
"task": "Add a footer row that spans both columns using <kbd>colspan=\"2\"</kbd>.",
"previewHTML": "",
"previewBaseCSS": "body { font-family: system-ui; padding: 20px; } table { border-collapse: collapse; width: 100%; max-width: 350px; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); } th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid #eee; } thead { background: steelblue; color: white; } tfoot { background: #f0f0f0; font-weight: 600; }",
"sandboxCSS": "",
"initialCode": "<table>\n <thead>\n <tr>\n <th>Item</th>\n <th>Price</th>\n </tr>\n </thead>\n <tbody>\n <tr>\n <td>Coffee</td>\n <td>$4</td>\n </tr>\n <tr>\n <td>Cake</td>\n <td>$6</td>\n </tr>\n </tbody>\n</table>",
"solution": "<table>\n <thead>\n <tr>\n <th>Item</th>\n <th>Price</th>\n </tr>\n </thead>\n <tbody>\n <tr>\n <td>Coffee</td>\n <td>$4</td>\n </tr>\n <tr>\n <td>Cake</td>\n <td>$6</td>\n </tr>\n </tbody>\n <tfoot>\n <tr>\n <td colspan=\"2\">Total: $10</td>\n </tr>\n </tfoot>\n</table>",
"previewContainer": "preview-area",
"validations": [
{
"type": "element_exists",
"value": "tfoot",
"message": "Add a <kbd>&lt;tfoot&gt;</kbd> section"
},
{
"type": "contains",
"value": "colspan",
"message": "Use <kbd>colspan</kbd> to span columns"
}
]
}
]
}

View File

@@ -0,0 +1,88 @@
{
"$schema": "../schemas/code-crispies-module-schema.json",
"id": "html-semantic",
"title": "Semantic HTML",
"mode": "html",
"description": "Use meaningful HTML elements to structure content properly.",
"difficulty": "beginner",
"lessons": [
{
"id": "semantic-1",
"title": "The <article> Element",
"description": "The <kbd>&lt;article&gt;</kbd> element represents self-contained content that could be distributed independently, like a blog post, news article, or comment.<br><br><pre>&lt;article&gt;\n &lt;h2&gt;Article Title&lt;/h2&gt;\n &lt;p&gt;Article content...&lt;/p&gt;\n&lt;/article&gt;</pre>",
"task": "Wrap the blog post content in an <kbd>&lt;article&gt;</kbd> element.",
"previewHTML": "",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } article { padding: 1rem; background: #f9f9f9; border-left: 4px solid steelblue; border-radius: 4px; } h2 { margin: 0 0 8px; color: steelblue; } p { margin: 0; color: #555; line-height: 1.5; }",
"sandboxCSS": "",
"codePrefix": "",
"initialCode": "<h2>My First Post</h2>\n<p>This is a blog post about learning HTML.</p>",
"codeSuffix": "",
"solution": "<article>\n<h2>My First Post</h2>\n<p>This is a blog post about learning HTML.</p>\n</article>",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "<article>",
"message": "Add an opening <kbd>&lt;article&gt;</kbd> tag"
},
{
"type": "contains",
"value": "</article>",
"message": "Add a closing <kbd>&lt;/article&gt;</kbd> tag"
}
]
},
{
"id": "semantic-2",
"title": "The <section> Element",
"description": "The <kbd>&lt;section&gt;</kbd> element represents a thematic grouping of content, typically with a heading. Use it to divide a page into logical sections.<br><br><pre>&lt;section&gt;\n &lt;h2&gt;Features&lt;/h2&gt;\n &lt;p&gt;Our product features...&lt;/p&gt;\n&lt;/section&gt;</pre>",
"task": "Wrap the features content in a <kbd>&lt;section&gt;</kbd> element.",
"previewHTML": "",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } section { padding: 1rem; background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); border-radius: 8px; } h2 { margin: 0 0 12px; } ul { margin: 0; padding-left: 1.5rem; } li { margin: 4px 0; color: #444; }",
"sandboxCSS": "",
"codePrefix": "",
"initialCode": "<h2>Features</h2>\n<ul>\n <li>Fast performance</li>\n <li>Easy to use</li>\n</ul>",
"codeSuffix": "",
"solution": "<section>\n<h2>Features</h2>\n<ul>\n <li>Fast performance</li>\n <li>Easy to use</li>\n</ul>\n</section>",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "<section>",
"message": "Add an opening <kbd>&lt;section&gt;</kbd> tag"
},
{
"type": "contains",
"value": "</section>",
"message": "Add a closing <kbd>&lt;/section&gt;</kbd> tag"
}
]
},
{
"id": "semantic-3",
"title": "The <aside> Element",
"description": "The <kbd>&lt;aside&gt;</kbd> element represents content tangentially related to the main content, like sidebars, pull quotes, or related links.<br><br><pre>&lt;aside&gt;\n &lt;h3&gt;Related&lt;/h3&gt;\n &lt;ul&gt;...&lt;/ul&gt;\n&lt;/aside&gt;</pre>",
"task": "Wrap the related links in an <kbd>&lt;aside&gt;</kbd> element.",
"previewHTML": "",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } aside { padding: 1rem; background: #fff8e7; border: 1px solid #ffe0a6; border-radius: 8px; } h3 { margin: 0 0 8px; color: #b8860b; font-size: 0.9rem; text-transform: uppercase; } ul { margin: 0; padding-left: 1.2rem; } li { margin: 4px 0; } a { color: #b8860b; }",
"sandboxCSS": "",
"codePrefix": "",
"initialCode": "<h3>Related Posts</h3>\n<ul>\n <li><a href=\"#\">CSS Basics</a></li>\n <li><a href=\"#\">HTML Tips</a></li>\n</ul>",
"codeSuffix": "",
"solution": "<aside>\n<h3>Related Posts</h3>\n<ul>\n <li><a href=\"#\">CSS Basics</a></li>\n <li><a href=\"#\">HTML Tips</a></li>\n</ul>\n</aside>",
"previewContainer": "preview-area",
"validations": [
{
"type": "contains",
"value": "<aside>",
"message": "Add an opening <kbd>&lt;aside&gt;</kbd> tag"
},
{
"type": "contains",
"value": "</aside>",
"message": "Add a closing <kbd>&lt;/aside&gt;</kbd> tag"
}
]
}
]
}

View File

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

View File

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

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

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

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

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

View File

@@ -3,7 +3,7 @@
"id": "welcome",
"title": "مرحباً",
"description": "ابدأ مع Code Crispies",
"mode": "html",
"mode": "css",
"difficulty": "beginner",
"excludeFromProgress": true,
"lessons": [

View File

@@ -3,7 +3,7 @@
"id": "welcome",
"title": "Willkommen",
"description": "Erste Schritte mit Code Crispies",
"mode": "html",
"mode": "css",
"difficulty": "beginner",
"excludeFromProgress": true,
"lessons": [

View File

@@ -3,7 +3,7 @@
"id": "welcome",
"title": "Bienvenido",
"description": "Comienza con Code Crispies",
"mode": "html",
"mode": "css",
"difficulty": "beginner",
"excludeFromProgress": true,
"lessons": [

View File

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

View File

@@ -3,7 +3,7 @@
"id": "welcome",
"title": "Witaj",
"description": "Rozpocznij przygodę z Code Crispies",
"mode": "html",
"mode": "css",
"difficulty": "beginner",
"excludeFromProgress": true,
"lessons": [

View File

@@ -3,7 +3,7 @@
"id": "welcome",
"title": "Ласкаво просимо",
"description": "Почніть з Code Crispies",
"mode": "html",
"mode": "css",
"difficulty": "beginner",
"excludeFromProgress": true,
"lessons": [

194
package-lock.json generated
View File

@@ -7,17 +7,21 @@
"": {
"name": "code-crispies",
"version": "1.0.0",
"license": "Copyright 2025 (c) Michael Czechowski",
"license": "Copyright 2026 (c) Michael Czechowski",
"dependencies": {
"@codemirror/autocomplete": "^6.20.0",
"@codemirror/commands": "^6.10.1",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.4",
"@emmetio/codemirror6-plugin": "^0.4.0",
"@supabase/supabase-js": "^2.90.1",
"codemirror": "^6.0.2",
"marked": "^17.0.1",
"whatwg-fetch": "^3.6.20"
},
"devDependencies": {
@@ -155,7 +159,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz",
"integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
@@ -168,7 +171,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.1.tgz",
"integrity": "sha512-uWDWFypNdQmz2y1LaNJzK7fL7TYKLeUAU0npEC685OKTF3KcQ2Vu3klIM78D7I6wGhktme0lh3CuQLv0ZCrD9Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.4.0",
@@ -181,7 +183,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
@@ -195,7 +196,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
@@ -209,9 +209,9 @@
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
"integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==",
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
@@ -223,12 +223,26 @@
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-markdown": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz",
"integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.3.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.11.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz",
"integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
@@ -265,7 +279,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
@@ -287,7 +300,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.4.tgz",
"integrity": "sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
@@ -383,7 +395,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -407,7 +418,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -973,9 +983,9 @@
}
},
"node_modules/@lezer/common": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.4.0.tgz",
"integrity": "sha512-DVeMRoGrgn/k45oQNu189BoW4SZwgZFzJ1+1TV5j2NJ/KFC83oa/enRqZSGshyeMk5cPWMhsKs9nx+8o0unwGg==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.0.tgz",
"integrity": "sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==",
"license": "MIT"
},
"node_modules/@lezer/css": {
@@ -1029,6 +1039,16 @@
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/markdown": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.3.tgz",
"integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
@@ -1354,6 +1374,86 @@
"win32"
]
},
"node_modules/@supabase/auth-js": {
"version": "2.90.1",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.90.1.tgz",
"integrity": "sha512-vxb66dgo6h3yyPbR06735Ps+dK3hj0JwS8w9fdQPVZQmocSTlKUW5MfxSy99mN0XqCCuLMQ3jCEiIIUU23e9ng==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.90.1",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.90.1.tgz",
"integrity": "sha512-x9mV9dF1Lam9qL3zlpP6mSM5C9iqMPtF5B/tU1Jj/F0ufX5mjDf9ghVBaErVxmrQJRL4+iMKWKY2GnODkpS8tw==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/postgrest-js": {
"version": "2.90.1",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.90.1.tgz",
"integrity": "sha512-jh6vqzaYzoFn3raaC0hcFt9h+Bt+uxNRBSdc7PfToQeRGk7PDPoweHsbdiPWREtDVTGKfu+PyPW9e2jbK+BCgQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.90.1",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.90.1.tgz",
"integrity": "sha512-PWbnEMkcQRuor8jhObp4+Snufkq8C6fBp+MchVp2qBPY1NXk/c3Iv3YyiFYVzo0Dzuw4nAlT4+ahuPggy4r32w==",
"license": "MIT",
"dependencies": {
"@types/phoenix": "^1.6.6",
"@types/ws": "^8.18.1",
"tslib": "2.8.1",
"ws": "^8.18.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.90.1",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.90.1.tgz",
"integrity": "sha512-GHY+Ps/K/RBfRj7kwx+iVf2HIdqOS43rM2iDOIDpapyUnGA9CCBFzFV/XvfzznGykd//z2dkGZhlZZprsVFqGg==",
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.90.1",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.90.1.tgz",
"integrity": "sha512-U8KaKGLUgTIFHtwEW1dgw1gK7XrdpvvYo7nzzqPx721GqPe8WZbAiLh/hmyKLGBYQ/mmQNr20vU9tWSDZpii3w==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.90.1",
"@supabase/functions-js": "2.90.1",
"@supabase/postgrest-js": "2.90.1",
"@supabase/realtime-js": "2.90.1",
"@supabase/storage-js": "2.90.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -1433,6 +1533,30 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.0.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz",
"integrity": "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/@types/phoenix": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
"integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz",
@@ -2092,6 +2216,15 @@
"node": ">= 14"
}
},
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -2222,7 +2355,6 @@
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
@@ -2319,6 +2451,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/marked": {
"version": "17.0.1",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz",
"integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@@ -2465,7 +2609,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -2991,13 +3134,24 @@
"node": ">=18"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"license": "MIT"
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -3096,7 +3250,6 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -3374,7 +3527,6 @@
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,12 @@
import { LessonEngine } from "./impl/LessonEngine.js";
import { CodeEditor, crispyEditorTheme } from "./impl/CodeEditor.js";
import { renderLesson, renderModuleList, renderLevelIndicator, updateActiveLessonInSidebar } from "./helpers/renderer.js";
import { renderLesson, renderModuleList, renderLevelIndicator, updateActiveLessonInSidebar, renderDifficultyBadge } from "./helpers/renderer.js";
import { loadModules } from "./config/lessons.js";
import { initI18n, t, getLanguage, setLanguage, applyTranslations } from "./i18n.js";
import { parseHash, updateHash, replaceHash, getShareableUrl, RouteType, navigateTo } from "./helpers/router.js";
import { sections, getSection, getModuleSection, getModulesBySection } from "./config/sections.js";
import { getRandomTemplate } from "./config/playground-templates.js";
import { initAuth, handleOAuthCallback } from "./auth.js";
// CodeMirror imports for syntax highlighting
import { EditorState } from "@codemirror/state";
@@ -17,6 +18,9 @@ import { css } from "@codemirror/lang-css";
function track(eventName, eventData = {}) {
if (typeof umami !== "undefined" && umami.track) {
umami.track(eventName, eventData);
console.debug("Track:", eventName, eventData);
} else {
console.debug("Track blocked (umami unavailable):", eventName, eventData);
}
}
@@ -147,6 +151,7 @@ const elements = {
previewSection: document.querySelector(".preview-section"),
prevBtn: document.getElementById("prev-btn"),
nextBtn: document.getElementById("next-btn"),
gameControls: document.querySelector(".game-controls"),
levelIndicator: document.getElementById("level-indicator"),
headerLevelPill: document.getElementById("header-level-pill"),
@@ -156,8 +161,12 @@ const elements = {
closeSidebar: document.getElementById("close-sidebar"),
moduleList: document.getElementById("module-list"),
footerLessonLinks: document.getElementById("footer-lesson-links"),
refFooterLessonLinks: document.getElementById("ref-footer-lesson-links"),
sectionFooterLessonLinks: document.getElementById("section-footer-lesson-links"),
progressFill: document.getElementById("progress-fill"),
progressText: document.getElementById("progress-text"),
progressCurrent: document.getElementById("progress-current"),
progressTotal: document.getElementById("progress-total"),
milestonesContainer: document.getElementById("milestones"),
resetBtn: document.getElementById("reset-btn"),
disableFeedbackToggle: document.getElementById("disable-feedback-toggle"),
@@ -223,6 +232,13 @@ function closeSidebar() {
function toggleExpectedResult() {
state.showExpected = !state.showExpected;
const engineState = lessonEngine.getCurrentState();
track("expected_toggle", {
show: state.showExpected,
module: engineState.module?.id,
lesson: engineState.lessonIndex
});
if (state.showExpected) {
elements.expectedOverlay.classList.add("visible");
elements.showExpectedBtn.textContent = t("hideExpected");
@@ -296,14 +312,53 @@ function showSuccessHint(message) {
// ================= PROGRESS DISPLAY =================
// Track last milestone to detect new achievements
let lastMilestoneReached = 0;
function updateProgressDisplay() {
const stats = lessonEngine.getProgressStats();
elements.progressFill.style.width = `${stats.percentComplete}%`;
elements.progressText.textContent = t("progressText", {
percent: stats.percentComplete,
completed: stats.totalCompleted,
// Update progress bar - shows progress towards next milestone
// CSS variable scales gradient so only first X% of colors show
const progressPercent = stats.progressToNext || 1;
elements.progressFill.style.width = `${progressPercent}%`;
elements.progressFill.style.setProperty('--progress-percent', progressPercent);
// Update progress current - show progress towards next milestone
elements.progressCurrent.textContent = `${stats.totalCompleted}/${stats.nextMilestone}`;
// Update progress total - show total lessons
elements.progressTotal.textContent = t("progressTotal", {
total: stats.totalLessons
});
// Update milestone indicators
if (elements.milestonesContainer) {
const milestoneEls = elements.milestonesContainer.querySelectorAll(".milestone");
milestoneEls.forEach((el) => {
const value = parseInt(el.dataset.value, 10);
el.classList.remove("reached", "current", "next", "just-reached");
if (stats.milestonesReached.includes(value)) {
el.classList.add("reached");
// Check if this milestone was just reached
if (value > lastMilestoneReached && value === stats.currentMilestone) {
el.classList.add("just-reached");
}
} else if (value === stats.nextMilestone) {
el.classList.add("next");
}
if (value === stats.currentMilestone) {
el.classList.add("current");
}
});
}
// Update last milestone for celebration detection
if (stats.currentMilestone > lastMilestoneReached) {
lastMilestoneReached = stats.currentMilestone;
}
}
// ================= USER SETTINGS =================
@@ -395,6 +450,9 @@ function initializeModules() {
// Use the new renderModuleList function with both callbacks
renderModuleList(elements.moduleList, modules, selectModule, selectLesson);
// Render footer lesson links (for all pages)
renderFooterLessonLinks();
// Handle route (home, section, or lesson)
handleRoute(false);
@@ -456,6 +514,13 @@ function selectLesson(moduleId, lessonIndex) {
loadCurrentLesson();
// Update section color coding (after loadCurrentLesson to ensure content is loaded first)
const newState = lessonEngine.getCurrentState();
updateSectionColor(getModuleSection(newState.module));
// Update nav highlight
updateNavHighlight({ type: RouteType.LESSON, moduleId, lessonIndex });
// Close sidebar after selection on mobile
if (window.innerWidth <= 768) {
closeSidebar();
@@ -478,6 +543,7 @@ function resetSuccessIndicators() {
elements.previewWrapper?.classList.remove("matched");
elements.previewWrapper?.classList.remove("completed-glow");
elements.previewSection?.classList.remove("matched");
elements.previewSection?.classList.remove("completed-glow");
// Remove completion badge if present
const badge = document.querySelector(".completion-badge");
@@ -507,6 +573,16 @@ function updateEditorForMode(mode) {
label: "CSS Editor",
cmMode: "css"
},
markdown: {
placeholder: "# Heading\n\nWrite your **Markdown** here...",
label: "Markdown Editor",
cmMode: "markdown"
},
javascript: {
placeholder: "// Write your JavaScript here...",
label: "JavaScript Editor",
cmMode: "javascript"
},
playground: {
placeholder: "<style>\n /* CSS here */\n</style>\n\n<!-- HTML here -->",
label: "HTML & CSS",
@@ -540,10 +616,12 @@ function loadCurrentLesson() {
elements.instructionsSection?.classList.add("hidden");
elements.editorSection?.classList.add("playground-mode");
elements.randomTemplateBtn?.classList.remove("hidden");
elements.showExpectedBtn?.classList.add("hidden");
} else {
elements.instructionsSection?.classList.remove("hidden");
elements.editorSection?.classList.remove("playground-mode");
elements.randomTemplateBtn?.classList.add("hidden");
elements.showExpectedBtn?.classList.remove("hidden");
}
// Add transition class for smooth content swap
@@ -581,40 +659,59 @@ function loadCurrentLesson() {
lesson
);
// Render difficulty badge
renderDifficultyBadge(elements.lessonTitleRow, lesson);
// Set user code in CodeMirror (clear history to prevent undo/redo across lessons)
// Pass codePrefix/codeSuffix as read-only zones for CSS mode
if (codeEditor) {
codeEditor.setValueAndClearHistory(engineState.userCode);
const prefix = lesson.codePrefix || "";
const suffix = lesson.codeSuffix || "";
codeEditor.setValueAndClearHistory(engineState.userCode, prefix, suffix);
}
// Update Run button text based on completion status
if (engineState.isCompleted) {
elements.runBtn.querySelector("span").textContent = t("rerun");
// Add completion badge if not present
if (!document.querySelector(".completion-badge")) {
// Add completion badge to difficulty-wrapper if not present
const wrapper = document.querySelector(".difficulty-wrapper");
if (wrapper && !wrapper.querySelector(".completion-badge")) {
const badge = document.createElement("span");
badge.className = "completion-badge";
badge.textContent = t("completed");
elements.lessonTitleRow.appendChild(badge);
wrapper.appendChild(badge);
}
// Show gradient border for completed lessons
// Show gradient border and glow for completed lessons
elements.previewWrapper?.classList.add("completed-glow");
elements.previewSection?.classList.add("completed-glow");
} else {
elements.runBtn.querySelector("span").textContent = t("run");
// Remove completion badge and border if exists
// Remove completion badge if exists
const badge = document.querySelector(".completion-badge");
if (badge) badge.remove();
elements.previewWrapper?.classList.remove("completed-glow");
elements.previewSection?.classList.remove("completed-glow");
}
// Update level indicator
renderLevelIndicator(elements.levelIndicator, engineState.lessonIndex + 1, engineState.totalLessons);
// Update level indicator (hide in playground mode)
if (isPlayground) {
elements.levelIndicator.classList.add("hidden");
} else {
elements.levelIndicator.classList.remove("hidden");
renderLevelIndicator(elements.levelIndicator, engineState.lessonIndex + 1, engineState.totalLessons);
}
// Header pill shows module name + level (clickable link to return to lesson)
if (elements.headerLevelPill && engineState.module) {
const label = t("lessonLabel");
elements.headerLevelPill.innerHTML = `<span class="header-module-name">${engineState.module.title}</span> <span class="header-level">${label} ${engineState.lessonIndex + 1} / ${engineState.totalLessons}</span>`;
if (isPlayground) {
// Playground: just show title, no lesson count
elements.headerLevelPill.innerHTML = `<span class="header-module-name">${engineState.module.title}</span>`;
} else {
const label = t("lessonLabel");
elements.headerLevelPill.innerHTML = `<span class="header-module-name">${engineState.module.title}</span> <span class="header-level">${label} ${engineState.lessonIndex + 1} / ${engineState.totalLessons}</span>`;
}
elements.headerLevelPill.href = `#${engineState.module.id}/${engineState.lessonIndex}`;
}
@@ -677,12 +774,13 @@ function handleEditorChange(code) {
function updateNavigationButtons() {
const engineState = lessonEngine.getCurrentState();
const isPlayground = engineState.lesson?.mode === "playground";
// Update button states
elements.prevBtn.disabled = !engineState.canGoPrev;
elements.nextBtn.disabled = !engineState.canGoNext;
elements.nextBtn.disabled = isPlayground || !engineState.canGoNext;
elements.prevBtn.classList.toggle("btn-disabled", !engineState.canGoPrev);
elements.nextBtn.classList.toggle("btn-disabled", !engineState.canGoNext);
elements.nextBtn.classList.toggle("btn-disabled", isPlayground || !engineState.canGoNext);
}
function nextLesson() {
@@ -694,16 +792,21 @@ function nextLesson() {
// Update URL
updateHash(newState.module.id, newState.lessonIndex);
if (newState.module.id !== prevModuleId) {
const moduleChanged = newState.module.id !== prevModuleId;
if (moduleChanged) {
updateModuleHighlight(newState.module.id);
updateSectionColor(getModuleSection(newState.module));
}
loadCurrentLesson();
if (moduleChanged) {
updateSectionColor(getModuleSection(newState.module));
updateNavHighlight({ type: RouteType.LESSON, moduleId: newState.module.id, lessonIndex: newState.lessonIndex });
}
}
}
function prevLesson() {
const prevModuleId = lessonEngine.getCurrentState().module?.id;
const engineState = lessonEngine.getCurrentState();
const prevModuleId = engineState.module?.id;
const success = lessonEngine.previousLesson();
if (success) {
const newState = lessonEngine.getCurrentState();
@@ -711,11 +814,15 @@ function prevLesson() {
// Update URL
updateHash(newState.module.id, newState.lessonIndex);
if (newState.module.id !== prevModuleId) {
const moduleChanged = newState.module.id !== prevModuleId;
if (moduleChanged) {
updateModuleHighlight(newState.module.id);
updateSectionColor(getModuleSection(newState.module));
}
loadCurrentLesson();
if (moduleChanged) {
updateSectionColor(getModuleSection(newState.module));
updateNavHighlight({ type: RouteType.LESSON, moduleId: newState.module.id, lessonIndex: newState.lessonIndex });
}
}
}
@@ -775,10 +882,12 @@ function loadRandomTemplate() {
}
function runCode() {
const userCode = codeEditor ? codeEditor.getValue() : "";
const userCode = codeEditor ? codeEditor.getEditableValue() : "";
const engineState = lessonEngine.getCurrentState();
const isPlayground = engineState.lesson?.mode === "playground";
track("run_code", { module: engineState.module?.id, lesson: engineState.lessonIndex, playground: isPlayground });
// Rotate the Run button icon
const runButtonImg = document.querySelector("#run-btn img");
if (runButtonImg) {
@@ -845,8 +954,9 @@ function runCode() {
state.animationTimeout = setTimeout(() => {
elements.previewWrapper?.classList.remove("matched");
elements.previewSection?.classList.remove("matched");
// Keep the gradient border visible after animation
// Keep the gradient border and glow visible after animation
elements.previewWrapper?.classList.add("completed-glow");
elements.previewSection?.classList.add("completed-glow");
state.animationTimeout = null;
}, 3500);
@@ -1309,6 +1419,143 @@ const sectionContent = {
</div>
</div>
</div>
`,
markdown: `
<div class="section-overview">
<p><strong>Markdown</strong> is a lightweight markup language created by John Gruber in 2004. It lets you write formatted text using plain text syntax that's easy to read and write. Markdown is used everywhere—from GitHub READMEs to documentation, note-taking apps, and content management systems.</p>
<p>The beauty of Markdown is its simplicity: <code># Heading</code> creates a heading, <code>**bold**</code> makes text bold, and <code>[link](url)</code> creates a link. No complex HTML tags needed. Markdown files can be converted to HTML, PDF, or many other formats.</p>
</div>
<div class="topic-row">
<div class="topic-text">
<h2>Headings & Structure</h2>
<p>Create document structure with headings using <code>#</code> symbols. One <code>#</code> for h1, two <code>##</code> for h2, up to six levels. This creates a clear hierarchy in your documents.</p>
<p>
<a href="#markdown-basics/0" class="topic-link">Practice headings →</a>
</p>
</div>
<div class="topic-code">
<div class="code-block">
<pre><code># Main Title
## Section
### Subsection
#### Detail</code></pre>
</div>
</div>
</div>
<div class="topic-row">
<div class="topic-text">
<h2>Text Formatting</h2>
<p>Emphasize text with <code>**bold**</code> or <code>*italic*</code>. Combine them with <code>***bold italic***</code>. Use backticks for <code>\`inline code\`</code> to highlight commands or code snippets in your text.</p>
<p>
<a href="#markdown-basics/2" class="topic-link">Practice formatting →</a>
</p>
</div>
<div class="topic-code">
<div class="code-block">
<pre><code>This is **bold** text.
This is *italic* text.
This is \`inline code\`.</code></pre>
</div>
</div>
</div>
<div class="topic-row">
<div class="topic-text">
<h2>Lists</h2>
<p>Create bullet lists with <code>-</code>, <code>*</code>, or <code>+</code>. Numbered lists use <code>1.</code>, <code>2.</code>, etc. Indent items with spaces to create nested lists for complex outlines.</p>
<p>
<a href="#markdown-basics/4" class="topic-link">Practice lists →</a>
</p>
</div>
<div class="topic-code">
<div class="code-block">
<pre><code>- First item
- Second item
- Nested item
1. Step one
2. Step two
3. Step three</code></pre>
</div>
</div>
</div>
<div class="topic-row">
<div class="topic-text">
<h2>Links & Images</h2>
<p>Create links with <code>[text](url)</code> syntax. Images use the same format with an exclamation mark: <code>![alt text](image-url)</code>. The alt text describes the image for accessibility.</p>
<p>
<a href="#markdown-basics/6" class="topic-link">Practice links →</a>
</p>
</div>
<div class="topic-code">
<div class="code-block">
<pre><code>[Visit Google](https://google.com)
![Logo](https://example.com/logo.png)</code></pre>
</div>
</div>
</div>
`,
javascript: `
<div class="section-overview">
<p><strong>JavaScript</strong> is the programming language of the web. It adds interactivity to HTML pages—responding to clicks, updating content dynamically, validating forms, and much more. Every modern browser includes a JavaScript engine, making it the most widely deployed programming language in the world.</p>
<p>These beginner lessons cover the fundamentals: declaring variables, selecting and modifying DOM elements, and handling user events. Each concept builds on the previous one, giving you the tools to make any web page interactive.</p>
</div>
<div class="topic-row">
<div class="topic-text">
<h2>Variables & Data Types</h2>
<p>JavaScript uses <code>const</code> for values that won't change and <code>let</code> for values that will. Template literals with backticks make it easy to embed expressions in strings using <code>\${...}</code> syntax.</p>
<p>Arrays store ordered collections in square brackets. Objects store key-value pairs in curly braces. These are the building blocks of every JavaScript program.</p>
<a href="#js-variables/0" class="topic-link">Learn JS Variables</a>
</div>
<div class="topic-code">
<div class="code-block">
<pre><code>const name = "Alice";
let count = 0;
count = count + 1;
const msg = \`Hello, \${name}!\`;
const colors = ["red", "green"];</code></pre>
</div>
</div>
</div>
<div class="topic-row">
<div class="topic-text">
<h2>DOM Manipulation</h2>
<p>The DOM (Document Object Model) is how JavaScript sees your HTML. Use <code>document.querySelector()</code> to find elements by CSS selector, then modify them with properties like <code>textContent</code>, <code>style</code>, and <code>classList</code>.</p>
<a href="#js-dom/0" class="topic-link">Practice DOM Methods</a>
</div>
<div class="topic-code">
<div class="code-block">
<pre><code>const title = document.querySelector('h1');
title.textContent = "New Title";
title.style.color = "coral";
title.classList.add("active");</code></pre>
</div>
</div>
</div>
<div class="topic-row">
<div class="topic-text">
<h2>Event Handling</h2>
<p>Events let your code respond to user actions. Use <code>addEventListener()</code> to run a function when something happens—a click, a keystroke, or an input change. The callback receives an event object with details about what happened.</p>
<a href="#js-events/0" class="topic-link">Handle Events</a>
</div>
<div class="topic-code">
<div class="code-block">
<pre><code>const btn = document.querySelector('.btn');
btn.addEventListener('click', () => {
alert('Clicked!');
});</code></pre>
</div>
</div>
</div>
`
};
@@ -1825,6 +2072,105 @@ const referenceContent = {
</section>
<p class="ref-see-also">Learn: <a href="#html">HTML Section</a> | Style with: <a href="#reference/css">CSS Properties</a></p>
`,
markdown: `
<h1>Markdown Syntax Reference</h1>
<p class="ref-intro">A quick guide to Markdown syntax for formatting text documents. Markdown is used in GitHub, documentation, and note-taking apps.</p>
<section class="ref-section">
<h2>Text Formatting</h2>
<table class="ref-table">
<thead><tr><th>Syntax</th><th>Result</th><th>Notes</th></tr></thead>
<tbody>
<tr><td><code>**bold**</code></td><td><strong>bold</strong></td><td>Or use __bold__</td></tr>
<tr><td><code>*italic*</code></td><td><em>italic</em></td><td>Or use _italic_</td></tr>
<tr><td><code>***bold italic***</code></td><td><strong><em>bold italic</em></strong></td><td>Combine both</td></tr>
<tr><td><code>~~strikethrough~~</code></td><td><s>strikethrough</s></td><td>GFM extension</td></tr>
<tr><td><code>\`inline code\`</code></td><td><code>inline code</code></td><td>Monospace font</td></tr>
</tbody>
</table>
</section>
<section class="ref-section">
<h2>Headings</h2>
<table class="ref-table">
<thead><tr><th>Syntax</th><th>Level</th><th>Usage</th></tr></thead>
<tbody>
<tr><td><code># Heading 1</code></td><td>h1</td><td>Document title</td></tr>
<tr><td><code>## Heading 2</code></td><td>h2</td><td>Main sections</td></tr>
<tr><td><code>### Heading 3</code></td><td>h3</td><td>Subsections</td></tr>
<tr><td><code>#### Heading 4</code></td><td>h4</td><td>Minor sections</td></tr>
<tr><td><code>##### Heading 5</code></td><td>h5</td><td>Rarely used</td></tr>
<tr><td><code>###### Heading 6</code></td><td>h6</td><td>Smallest heading</td></tr>
</tbody>
</table>
</section>
<section class="ref-section">
<h2>Lists</h2>
<table class="ref-table">
<thead><tr><th>Syntax</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
<tr><td><code>- Item</code></td><td>Unordered</td><td>Or use * or +</td></tr>
<tr><td><code>1. Item</code></td><td>Ordered</td><td>Numbers auto-increment</td></tr>
<tr><td><code> - Nested</code></td><td>Nested list</td><td>2-space indent</td></tr>
<tr><td><code>- [x] Task</code></td><td>Task list</td><td>GFM extension</td></tr>
<tr><td><code>- [ ] Task</code></td><td>Unchecked task</td><td>Interactive checkboxes</td></tr>
</tbody>
</table>
</section>
<section class="ref-section">
<h2>Links & Images</h2>
<table class="ref-table">
<thead><tr><th>Syntax</th><th>Purpose</th><th>Example</th></tr></thead>
<tbody>
<tr><td><code>[text](url)</code></td><td>Inline link</td><td>[Google](https://google.com)</td></tr>
<tr><td><code>[text](url "title")</code></td><td>Link with tooltip</td><td>Hover text</td></tr>
<tr><td><code>![alt](url)</code></td><td>Image</td><td>Alt text for accessibility</td></tr>
<tr><td><code>&lt;url&gt;</code></td><td>Auto-link</td><td>URLs become clickable</td></tr>
<tr><td><code>[ref]: url</code></td><td>Reference link</td><td>Define at doc bottom</td></tr>
</tbody>
</table>
</section>
<section class="ref-section">
<h2>Code Blocks</h2>
<table class="ref-table">
<thead><tr><th>Syntax</th><th>Purpose</th><th>Notes</th></tr></thead>
<tbody>
<tr><td><code>\`\`\`</code></td><td>Fenced code</td><td>3 backticks or tildes</td></tr>
<tr><td><code>\`\`\`js</code></td><td>Syntax highlight</td><td>Add language identifier</td></tr>
<tr><td><code> code</code></td><td>Indented code</td><td>4-space indent</td></tr>
</tbody>
</table>
</section>
<section class="ref-section">
<h2>Block Elements</h2>
<table class="ref-table">
<thead><tr><th>Syntax</th><th>Element</th><th>Notes</th></tr></thead>
<tbody>
<tr><td><code>> Quote</code></td><td>Blockquote</td><td>Nest with >></td></tr>
<tr><td><code>---</code></td><td>Horizontal rule</td><td>Or *** or ___</td></tr>
<tr><td><code>| A | B |</code></td><td>Table</td><td>GFM extension</td></tr>
</tbody>
</table>
</section>
<section class="ref-section">
<h2>Tables (GFM)</h2>
<div class="ref-example">
<pre><code>| Header 1 | Header 2 |
|----------|----------|
| Cell 1 | Cell 2 |
| Cell 3 | Cell 4 |</code></pre>
</div>
<p>Use colons for alignment: <code>:---</code> (left), <code>:---:</code> (center), <code>---:</code> (right)</p>
</section>
<p class="ref-see-also">Learn: <a href="#markdown">Markdown Section</a> | Also try: <a href="#html">HTML Elements</a></p>
`
};
@@ -1852,7 +2198,7 @@ function stripHtml(html) {
* Update page meta tags based on current route for SEO
*/
function updatePageMeta(route) {
const defaultTitle = "Code Crispies - Learn HTML & CSS Interactively | Free Coding Practice";
const defaultTitle = "CODE CRISPIES - Learn HTML & CSS Interactively | Free Coding Practice";
const defaultDesc =
"Master HTML, CSS, and Tailwind through hands-on coding exercises. Free, open-source learning platform with instant feedback. No account required.";
@@ -1870,9 +2216,9 @@ function updatePageMeta(route) {
break;
case RouteType.SECTION: {
const sectionNames = { css: "CSS", html: "HTML", tailwind: "Tailwind CSS" };
const sectionNames = { css: "CSS", html: "HTML", tailwind: "Tailwind CSS", markdown: "Markdown" };
const sectionName = sectionNames[route.sectionId] || route.sectionId;
title = `${sectionName} Lessons - Code Crispies | Learn ${sectionName}`;
title = `${sectionName} Lessons - CODE CRISPIES | Learn ${sectionName}`;
description = `Learn ${sectionName} through interactive coding exercises. Hands-on practice with instant feedback.`;
break;
}
@@ -1881,7 +2227,7 @@ function updatePageMeta(route) {
const module = lessonEngine.modules.find((m) => m.id === route.moduleId);
const lesson = module?.lessons[route.lessonIndex];
if (module && lesson) {
title = `${lesson.title} - ${module.title} | Code Crispies`;
title = `${lesson.title} - ${module.title} | CODE CRISPIES`;
const lessonDesc = stripHtml(lesson.description || lesson.task);
description = lessonDesc.length > 155 ? lessonDesc.slice(0, 152) + "..." : lessonDesc || defaultDesc;
}
@@ -1894,10 +2240,11 @@ function updatePageMeta(route) {
selectors: "CSS Selectors",
flexbox: "Flexbox",
grid: "CSS Grid",
html: "HTML Elements"
html: "HTML Elements",
markdown: "Markdown Syntax"
};
const refName = refNames[route.refId] || "Reference";
title = `${refName} Reference - Code Crispies`;
title = `${refName} Reference - CODE CRISPIES`;
description = `Quick reference guide for ${refName}. Syntax, examples, and common patterns for web development.`;
break;
}
@@ -1913,13 +2260,13 @@ function updatePageMeta(route) {
// Update Open Graph tags
const ogTitle = document.querySelector('meta[property="og:title"]');
const ogDesc = document.querySelector('meta[property="og:description"]');
if (ogTitle) ogTitle.setAttribute("content", title.replace(" | Code Crispies", "").replace(" - Code Crispies", ""));
if (ogTitle) ogTitle.setAttribute("content", title.replace(" | CODE CRISPIES", "").replace(" - CODE CRISPIES", ""));
if (ogDesc) ogDesc.setAttribute("content", description);
// Update Twitter tags
const twitterTitle = document.querySelector('meta[name="twitter:title"]');
const twitterDesc = document.querySelector('meta[name="twitter:description"]');
if (twitterTitle) twitterTitle.setAttribute("content", title.replace(" | Code Crispies", "").replace(" - Code Crispies", ""));
if (twitterTitle) twitterTitle.setAttribute("content", title.replace(" | CODE CRISPIES", "").replace(" - CODE CRISPIES", ""));
if (twitterDesc) twitterDesc.setAttribute("content", description);
}
@@ -1994,6 +2341,11 @@ function updateSectionColor(sectionId) {
} else {
document.body.removeAttribute("data-section");
}
// Update code editor theme for section
if (codeEditor) {
codeEditor.setSection(sectionId);
}
}
/**
@@ -2002,7 +2354,6 @@ function updateSectionColor(sectionId) {
function showLandingPage() {
hideAllPages();
elements.landingPage?.classList.remove("hidden");
window.scrollTo(0, 0);
// Reset section color on landing page
updateSectionColor(null);
@@ -2012,16 +2363,17 @@ function showLandingPage() {
// Render footer lesson links
renderFooterLessonLinks();
// Scroll to top after content is rendered
requestAnimationFrame(() => window.scrollTo(0, 0));
}
/**
* Render module links in the landing page footer, grouped by section
*/
function renderFooterLessonLinks() {
if (!elements.footerLessonLinks) return;
const modules = lessonEngine.modules || [];
const sectionGroups = { css: [], html: [] };
const sectionGroups = { css: [], html: [], javascript: [] };
modules.forEach((module) => {
if (module.excludeFromProgress) return;
@@ -2042,14 +2394,23 @@ function renderFooterLessonLinks() {
html += "</div>";
});
elements.footerLessonLinks.innerHTML = html;
// Update all footer lesson links
if (elements.footerLessonLinks) {
elements.footerLessonLinks.innerHTML = html;
}
if (elements.refFooterLessonLinks) {
elements.refFooterLessonLinks.innerHTML = html;
}
if (elements.sectionFooterLessonLinks) {
elements.sectionFooterLessonLinks.innerHTML = html;
}
}
/**
* Update progress indicators on landing page
*/
function updateLandingProgress() {
["css", "html", "tailwind"].forEach((sectionId) => {
["css", "html", "markdown", "javascript"].forEach((sectionId) => { // tailwind temporarily disabled
const progressEl = document.getElementById(`${sectionId}-progress`);
if (progressEl) {
const sectionModules = getModulesBySection(lessonEngine.modules, sectionId);
@@ -2077,7 +2438,6 @@ function updateLandingProgress() {
function showSectionPage(sectionId) {
hideAllPages();
elements.sectionPage?.classList.remove("hidden");
window.scrollTo(0, 0);
// Update section color
updateSectionColor(sectionId);
@@ -2120,6 +2480,9 @@ function showSectionPage(sectionId) {
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
if (elements.sectionProgressFill) elements.sectionProgressFill.style.width = `${percent}%`;
if (elements.sectionProgressText) elements.sectionProgressText.textContent = `${completed} of ${total} lessons complete`;
// Scroll to top after content is rendered
requestAnimationFrame(() => window.scrollTo(0, 0));
}
/**
@@ -2128,13 +2491,12 @@ function showSectionPage(sectionId) {
function showReferencePage(refId) {
hideAllPages();
elements.referencePage?.classList.remove("hidden");
window.scrollTo(0, 0);
// Default to CSS if no refId
const activeRef = refId || "css";
// Map reference to section for color coding
const refToSection = { css: "css", selectors: "css", flexbox: "css", grid: "css", html: "html" };
const refToSection = { css: "css", selectors: "css", flexbox: "css", grid: "css", html: "html", markdown: "markdown" };
updateSectionColor(refToSection[activeRef] || "css");
// Track reference page view
@@ -2156,6 +2518,9 @@ function showReferencePage(refId) {
} else if (elements.referenceBody) {
elements.referenceBody.innerHTML = `<p>Reference for "${activeRef}" coming soon...</p>`;
}
// Scroll to top after content is rendered
requestAnimationFrame(() => window.scrollTo(0, 0));
}
/**
@@ -2337,6 +2702,11 @@ function init() {
// Initialize i18n before anything else
initI18n();
// Set dynamic year in footer
document.querySelectorAll(".current-year").forEach((el) => {
el.textContent = new Date().getFullYear();
});
loadUserSettings();
// Restore cached lesson content immediately to avoid "Loading..." flash
@@ -2348,22 +2718,34 @@ function init() {
// Set timeout to show fallback if loading takes too long
loadingTimeout = setTimeout(showLoadingFallback, 3000);
// Load modules after editor is ready
initializeModules();
// Handle OAuth callback FIRST (tokens are in URL hash, must run before router)
handleOAuthCallback().then(() => {
// Load modules (this also calls handleRoute inside)
initializeModules();
// Initialize URL router for shareable links
initRouter();
// Initialize URL router for browser back/forward
initRouter();
// Initialize authentication
initAuth(lessonEngine);
});
// Sidebar controls
elements.menuBtn.addEventListener("click", openSidebar);
elements.closeSidebar.addEventListener("click", closeSidebar);
elements.sidebarBackdrop.addEventListener("click", closeSidebar);
// Sidebar nav links (mobile) - close sidebar on click
document.querySelectorAll(".sidebar-nav-link").forEach((link) => {
link.addEventListener("click", closeSidebar);
});
// Logo click - navigate to home landing
elements.logoLink.addEventListener("click", (e) => {
e.preventDefault();
navigateTo("");
showLandingPage();
track("logo_click");
});
// Language select
@@ -2416,10 +2798,42 @@ function init() {
});
elements.copyUrlBtn.addEventListener("click", copyShareUrl);
// Legal dialogs (Privacy & Imprint)
const privacyDialog = document.getElementById("privacy-dialog");
const imprintDialog = document.getElementById("imprint-dialog");
document.querySelectorAll(".privacy-link").forEach((btn) => {
btn.addEventListener("click", () => {
privacyDialog?.showModal();
track("privacy_open");
});
});
document.querySelectorAll(".imprint-link").forEach((btn) => {
btn.addEventListener("click", () => {
imprintDialog?.showModal();
track("imprint_open");
});
});
document.querySelector(".privacy-dialog-close")?.addEventListener("click", () => {
privacyDialog?.close();
});
document.querySelector(".imprint-dialog-close")?.addEventListener("click", () => {
imprintDialog?.close();
});
privacyDialog?.addEventListener("click", (e) => {
if (e.target === privacyDialog) privacyDialog.close();
});
imprintDialog?.addEventListener("click", (e) => {
if (e.target === imprintDialog) imprintDialog.close();
});
// Settings
elements.disableFeedbackToggle.addEventListener("change", (e) => {
state.userSettings.disableFeedbackErrors = !e.target.checked;
saveUserSettings();
track("setting_change", { setting: "feedback_errors", enabled: e.target.checked });
});
// Click on editor content to focus CodeMirror
@@ -2427,6 +2841,46 @@ function init() {
if (codeEditor) codeEditor.focus();
});
// Track clicks on "Coming Soon" disabled topic links
document.addEventListener("click", (e) => {
const disabledLink = e.target.closest(".topic-link-disabled");
if (disabledLink) {
const topicText = disabledLink.textContent.replace("Coming Soon", "").trim();
track("coming_soon_click", { topic: topicText });
}
});
// Track external link clicks
document.addEventListener("click", (e) => {
const link = e.target.closest('a[target="_blank"]');
if (link) {
track("external_link", { url: link.href, text: link.textContent.trim() });
}
});
// Track header nav link clicks (CSS, HTML, Tailwind)
document.querySelectorAll(".nav-link[data-section]").forEach((link) => {
link.addEventListener("click", () => {
track("header_nav_click", { section: link.dataset.section });
});
});
// Track footer link clicks
document.addEventListener("click", (e) => {
const footerLink = e.target.closest(".landing-footer a, .section-footer a, .reference-footer a");
if (footerLink && !footerLink.target) {
track("footer_link", { href: footerLink.getAttribute("href"), text: footerLink.textContent.trim() });
}
});
// Track practice/reference cross-links
document.addEventListener("click", (e) => {
const refLink = e.target.closest(".ref-see-also a");
if (refLink) {
track("practice_link", { href: refLink.getAttribute("href"), text: refLink.textContent.trim() });
}
});
// Keyboard shortcuts
document.addEventListener("keydown", (e) => {
// Ctrl+Enter to run code
@@ -2454,6 +2908,27 @@ function init() {
track("support_click", { location: "landing" });
}
});
// Newsletter form submission
const newsletterForm = document.getElementById("newsletter-form");
const newsletterThanks = document.getElementById("newsletter-thanks");
newsletterForm?.addEventListener("submit", async (e) => {
e.preventDefault();
const emailInput = document.getElementById("newsletter-email");
const email = emailInput?.value;
if (email) {
// Import newsletter helper dynamically to avoid loading Supabase if not needed
try {
const { newsletter } = await import("./supabase.js");
await newsletter.subscribe(email);
} catch (err) {
console.error("Newsletter subscription error:", err);
}
track("newsletter_signup", { email: email });
newsletterForm.classList.add("hidden");
newsletterThanks?.classList.remove("hidden");
}
});
}
// Start the application

526
src/auth.js Normal file
View File

@@ -0,0 +1,526 @@
import { t, applyTranslations } from "./i18n.js";
// Analytics tracking helper
function track(eventName, eventData = {}) {
if (typeof umami !== "undefined" && umami.track) {
umami.track(eventName, eventData);
}
}
let currentUser = null;
let oauthHandled = false;
let lessonEngineRef = null;
let authModule = null;
let progressModule = null;
let supabaseAvailable = false;
/**
* Check for OAuth callback tokens in URL hash BEFORE router runs.
* Call this before initializing the router.
* @returns {Promise<boolean>} true if OAuth callback was detected and handled
*/
export async function handleOAuthCallback() {
const hash = window.location.hash;
// Check if hash contains OAuth tokens (access_token, error, etc.)
if (!hash.includes("access_token") && !hash.includes("error_description") && !hash.includes("refresh_token")) {
return false;
}
try {
const supabaseModule = await import("./supabase.js");
if (!supabaseModule.isConfigured) {
return false;
}
// Parse tokens from hash
const params = new URLSearchParams(hash.substring(1));
const accessToken = params.get("access_token");
const refreshToken = params.get("refresh_token");
if (accessToken && refreshToken) {
// Explicitly set the session with tokens from URL
const { data, error } = await supabaseModule.auth.setSession({
access_token: accessToken,
refresh_token: refreshToken
});
if (!error && data?.session) {
oauthHandled = true;
const provider = data.session.user?.app_metadata?.provider || "oauth";
track("auth_login", { method: provider });
}
}
// Restore the original route (saved before OAuth redirect)
const savedRoute = localStorage.getItem("codeCrispies.oauthReturnRoute");
if (savedRoute) {
localStorage.removeItem("codeCrispies.oauthReturnRoute");
window.history.replaceState(null, "", window.location.pathname + savedRoute);
} else {
// No saved route - just clear the hash
window.history.replaceState(null, "", window.location.pathname);
}
return true;
} catch (e) {
console.error("[Auth] OAuth callback handling failed:", e.message);
return false;
}
}
/**
* Initialize the auth system
* @param {Object} engine - The LessonEngine instance
*/
export async function initAuth(engine) {
lessonEngineRef = engine;
// Try to load Supabase - if not configured, auth is disabled
try {
const supabaseModule = await import("./supabase.js");
// Check if Supabase is configured via environment variables
if (!supabaseModule.isConfigured) {
console.log("Supabase not configured - auth disabled");
hideAuthUI();
return;
}
authModule = supabaseModule.auth;
progressModule = supabaseModule.progressDB;
supabaseAvailable = true;
} catch (e) {
console.log("Supabase not available - auth disabled:", e.message);
hideAuthUI();
return;
}
// Listen for auth changes
authModule.onAuthStateChange((event, session) => {
if (event === "SIGNED_IN" && session?.user) {
handleLogin(session.user);
} else if (event === "SIGNED_OUT") {
handleLogout();
}
});
// Check initial session
try {
const { data } = await authModule.getSession();
if (data?.session?.user) handleLogin(data.session.user);
} catch (e) {
// Session check failed - continue without auth
}
// Attach form handlers
setupAuthForms();
}
function hideAuthUI() {
document.getElementById("auth-trigger-header")?.classList.add("hidden");
document.querySelector(".sidebar-auth-box")?.classList.add("hidden");
}
async function handleLogin(user) {
currentUser = user;
updateAuthUI(user);
if (!progressModule) return;
// Load cloud progress
const { data } = await progressModule.load(user.id);
if (data) {
// Merge with localStorage (cloud wins for conflicts)
mergeProgress(data);
} else {
// First login: upload localStorage to cloud
await syncToCloud();
}
}
function handleLogout() {
currentUser = null;
updateAuthUI(null);
// Keep localStorage progress, just disconnect from cloud
}
function updateAuthUI(user) {
// Header elements
const authTriggerHeader = document.getElementById("auth-trigger-header");
const userEmailHeader = document.getElementById("user-email-header");
// Sidebar elements
const authTriggerSidebar = document.getElementById("auth-trigger-sidebar");
const authTriggerMobile = document.getElementById("auth-trigger-mobile");
const userMenuSidebar = document.getElementById("user-menu-sidebar");
const userEmailSidebar = document.getElementById("user-email-sidebar");
const sidebarHint = document.querySelector(".sidebar-auth-hint");
if (user) {
authTriggerHeader?.classList.add("hidden");
userEmailHeader?.classList.remove("hidden");
authTriggerSidebar?.classList.add("hidden");
authTriggerMobile?.classList.add("hidden");
userMenuSidebar?.classList.remove("hidden");
sidebarHint?.classList.add("hidden");
if (userEmailHeader) userEmailHeader.textContent = user.email;
if (userEmailSidebar) userEmailSidebar.textContent = user.email;
} else {
authTriggerHeader?.classList.remove("hidden");
userEmailHeader?.classList.add("hidden");
authTriggerSidebar?.classList.remove("hidden");
authTriggerMobile?.classList.remove("hidden");
userMenuSidebar?.classList.add("hidden");
sidebarHint?.classList.remove("hidden");
}
}
export async function syncToCloud() {
if (!currentUser || !progressModule) return;
const progress = JSON.parse(
localStorage.getItem("codeCrispies.progress") || "{}"
);
const userCodeEntries = JSON.parse(
localStorage.getItem("codeCrispies.userCode") || "[]"
);
const userCode = Object.fromEntries(userCodeEntries);
const settings = JSON.parse(
localStorage.getItem("codeCrispies.settings") || "{}"
);
const language = localStorage.getItem("codeCrispies.language") || "en";
await progressModule.save(currentUser.id, progress, userCode, settings, language);
}
function mergeProgress(cloudData) {
// Update localStorage with cloud data
localStorage.setItem(
"codeCrispies.progress",
JSON.stringify(cloudData.progress)
);
localStorage.setItem(
"codeCrispies.userCode",
JSON.stringify(Object.entries(cloudData.user_code))
);
localStorage.setItem(
"codeCrispies.settings",
JSON.stringify(cloudData.settings)
);
localStorage.setItem("codeCrispies.language", cloudData.language);
// Reload engine state
if (lessonEngineRef) {
lessonEngineRef.loadUserProgress();
lessonEngineRef.loadUserCodeFromStorage();
}
}
export function isLoggedIn() {
return supabaseAvailable && currentUser !== null;
}
export function getCurrentUser() {
return currentUser;
}
// Debounce utility
function debounce(fn, delay) {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}
// Export debounced sync for use by LessonEngine
export const debouncedSyncToCloud = debounce(() => syncToCloud(), 2000);
function setupAuthForms() {
const authDialog = document.getElementById("auth-dialog");
const loginForm = document.getElementById("login-form");
const signupForm = document.getElementById("signup-form");
const resetForm = document.getElementById("reset-form");
// Form submissions
loginForm?.addEventListener("submit", handleLoginSubmit);
signupForm?.addEventListener("submit", handleSignupSubmit);
resetForm?.addEventListener("submit", handleResetSubmit);
// Form switchers
document
.getElementById("show-signup")
?.addEventListener("click", () => switchForm("signup"));
document
.getElementById("show-login")
?.addEventListener("click", () => switchForm("login"));
document
.getElementById("show-reset")
?.addEventListener("click", () => switchForm("reset"));
// Dialog triggers (header, sidebar, and mobile)
document
.getElementById("auth-trigger-header")
?.addEventListener("click", () => {
authDialog?.showModal();
});
document
.getElementById("auth-trigger-sidebar")
?.addEventListener("click", () => {
authDialog?.showModal();
});
document
.getElementById("auth-trigger-mobile")
?.addEventListener("click", () => {
authDialog?.showModal();
});
// Logout button (sidebar only)
document
.getElementById("logout-btn-sidebar")
?.addEventListener("click", async () => {
await authModule?.signOut();
track("auth_logout");
});
// Delete account button and dialog
const deleteDialog = document.getElementById("delete-account-dialog");
document
.getElementById("delete-account-btn")
?.addEventListener("click", () => {
deleteDialog?.showModal();
});
document
.getElementById("cancel-delete")
?.addEventListener("click", () => {
deleteDialog?.close();
});
document
.getElementById("delete-dialog-close")
?.addEventListener("click", () => {
deleteDialog?.close();
});
deleteDialog?.addEventListener("click", (e) => {
if (e.target === deleteDialog) deleteDialog.close();
});
document
.getElementById("confirm-delete")
?.addEventListener("click", async () => {
const errorEl = document.getElementById("delete-account-error");
const confirmBtn = document.getElementById("confirm-delete");
confirmBtn.disabled = true;
const { error } = await authModule.deleteAccount();
if (error) {
errorEl.textContent = error.message;
errorEl.classList.remove("hidden");
confirmBtn.disabled = false;
} else {
errorEl.classList.add("hidden");
deleteDialog.close();
track("auth_delete_account");
// Sign out and clear local state
await authModule.signOut();
}
});
// OAuth buttons - save current route before redirect
document.getElementById("google-login")?.addEventListener("click", async () => {
// Save current route to restore after OAuth
const currentHash = window.location.hash;
if (currentHash && !currentHash.includes("access_token")) {
localStorage.setItem("codeCrispies.oauthReturnRoute", currentHash);
}
const { error } = await authModule?.signInWithGoogle() ?? { error: null };
if (error) {
showOAuthError(error.message);
}
});
document.getElementById("github-login")?.addEventListener("click", async () => {
// Save current route to restore after OAuth
const currentHash = window.location.hash;
if (currentHash && !currentHash.includes("access_token")) {
localStorage.setItem("codeCrispies.oauthReturnRoute", currentHash);
}
const { error } = await authModule?.signInWithGitHub() ?? { error: null };
if (error) {
showOAuthError(error.message);
}
});
// Close dialog on backdrop click
authDialog?.addEventListener("click", (e) => {
if (e.target === authDialog) authDialog.close();
});
// Close button
authDialog?.querySelector(".close-dialog")?.addEventListener("click", () => {
authDialog.close();
});
}
async function handleLoginSubmit(e) {
e.preventDefault();
const email = document.getElementById("login-email").value;
const password = document.getElementById("login-password").value;
const errorEl = document.getElementById("login-error");
const submitBtn = e.target.querySelector('button[type="submit"]');
// Disable button while processing
submitBtn.disabled = true;
const { error } = await authModule.signIn(email, password);
submitBtn.disabled = false;
if (error) {
errorEl.textContent = error.message;
errorEl.classList.remove("hidden");
} else {
errorEl.classList.add("hidden");
document.getElementById("auth-dialog").close();
track("auth_login", { method: "email" });
}
}
async function handleSignupSubmit(e) {
e.preventDefault();
const email = document.getElementById("signup-email").value;
const password = document.getElementById("signup-password").value;
const confirm = document.getElementById("signup-confirm").value;
const errorEl = document.getElementById("signup-error");
const submitBtn = e.target.querySelector('button[type="submit"]');
if (password !== confirm) {
errorEl.textContent = t("authPasswordMismatch") || "Passwords do not match";
errorEl.classList.remove("hidden");
return;
}
// Disable button while processing
submitBtn.disabled = true;
const { error } = await authModule.signUp(email, password);
submitBtn.disabled = false;
if (error) {
errorEl.textContent = error.message;
errorEl.classList.remove("hidden");
document.getElementById("signup-success")?.classList.add("hidden");
} else {
errorEl.classList.add("hidden");
// Show success message
const successEl = document.getElementById("signup-success");
successEl?.classList.remove("hidden");
// Hide the form fields and button
e.target.querySelectorAll(".form-field, button[type='submit']").forEach(el => {
el.classList.add("hidden");
});
track("auth_signup", { method: "email" });
}
}
async function handleResetSubmit(e) {
e.preventDefault();
const email = document.getElementById("reset-email").value;
const errorEl = document.getElementById("reset-error");
const successEl = document.getElementById("reset-success");
const submitBtn = e.target.querySelector('button[type="submit"]');
// Disable button while processing
submitBtn.disabled = true;
const { error } = await authModule.resetPassword(email);
submitBtn.disabled = false;
if (error) {
errorEl.textContent = error.message;
errorEl.classList.remove("hidden");
successEl.classList.add("hidden");
} else {
errorEl.classList.add("hidden");
successEl.classList.remove("hidden");
}
}
function showOAuthError(message) {
// Show error in the currently visible form's error element
const loginError = document.getElementById("login-error");
const signupError = document.getElementById("signup-error");
// Use whichever form is visible
const errorEl = !document.getElementById("login-form")?.classList.contains("hidden")
? loginError
: signupError;
if (errorEl) {
errorEl.textContent = message;
errorEl.classList.remove("hidden");
}
}
function switchForm(formName) {
const loginForm = document.getElementById("login-form");
const signupForm = document.getElementById("signup-form");
const resetForm = document.getElementById("reset-form");
const showSignup = document.getElementById("show-signup");
const showLogin = document.getElementById("show-login");
const showReset = document.getElementById("show-reset");
const titleEl = document.getElementById("auth-dialog-title");
const socialSection = document.querySelector(".auth-social");
// Hide all forms
loginForm?.classList.add("hidden");
signupForm?.classList.add("hidden");
resetForm?.classList.add("hidden");
// Show the selected form
if (formName === "login") {
loginForm?.classList.remove("hidden");
showSignup?.classList.remove("hidden");
showLogin?.classList.add("hidden");
showReset?.classList.remove("hidden");
socialSection?.classList.remove("hidden");
if (titleEl) titleEl.setAttribute("data-i18n", "authLogin");
} else if (formName === "signup") {
signupForm?.classList.remove("hidden");
// Reset signup form to initial state (in case it was showing success)
signupForm?.querySelectorAll(".form-field, button[type='submit']").forEach(el => {
el.classList.remove("hidden");
});
signupForm?.reset();
showSignup?.classList.add("hidden");
showLogin?.classList.remove("hidden");
showReset?.classList.add("hidden");
socialSection?.classList.remove("hidden");
if (titleEl) titleEl.setAttribute("data-i18n", "authSignUp");
} else if (formName === "reset") {
resetForm?.classList.remove("hidden");
showSignup?.classList.add("hidden");
showLogin?.classList.remove("hidden");
showReset?.classList.add("hidden");
socialSection?.classList.add("hidden");
if (titleEl) titleEl.setAttribute("data-i18n", "authResetPassword");
}
// Clear error messages
document.getElementById("login-error")?.classList.add("hidden");
document.getElementById("signup-error")?.classList.add("hidden");
document.getElementById("reset-error")?.classList.add("hidden");
document.getElementById("reset-success")?.classList.add("hidden");
// Apply translations to updated elements
applyTranslations();
}

View File

@@ -16,11 +16,24 @@ import htmlElementsEN from "../../lessons/20-html-elements.json";
import htmlFormsBasicEN from "../../lessons/21-html-forms-basic.json";
import htmlFormsValidationEN from "../../lessons/22-html-forms-validation.json";
import htmlDetailsSummaryEN from "../../lessons/23-html-details-summary.json";
import htmlProgressMeterEN from "../../lessons/24-html-progress-meter.json";
import htmlDatalistEN from "../../lessons/25-html-datalist.json";
import htmlDialogEN from "../../lessons/27-html-dialog.json";
import htmlFieldsetEN from "../../lessons/28-html-forms-fieldset.json";
import htmlFigureEN from "../../lessons/29-html-figure.json";
import htmlTablesEN from "../../lessons/30-html-tables.json";
import htmlSvgEN from "../../lessons/32-html-svg.json";
import htmlSemanticEN from "../../lessons/33-html-semantic.json";
import flexboxEN from "../../lessons/flexbox.json";
import gridEN from "../../lessons/grid.json";
import gradientsEN from "../../lessons/09-gradients.json";
import filtersEN from "../../lessons/11-filters.json";
import positioningEN from "../../lessons/12-positioning.json";
import pseudoElementsEN from "../../lessons/13-pseudo-elements.json";
import markdownBasicsEN from "../../lessons/40-markdown-basics.json";
import jsVariablesEN from "../../lessons/50-js-variables.json";
import jsDomEN from "../../lessons/51-js-dom.json";
import jsEventsEN from "../../lessons/52-js-events.json";
import playgroundEN from "../../lessons/98-playground.json";
import goodbyeEN from "../../lessons/99-goodbye.json";
@@ -35,6 +48,10 @@ import htmlElementsDE from "../../lessons/de/20-html-elements.json";
import htmlFormsBasicDE from "../../lessons/de/21-html-forms-basic.json";
import htmlFormsValidationDE from "../../lessons/de/22-html-forms-validation.json";
import htmlDetailsSummaryDE from "../../lessons/de/23-html-details-summary.json";
import htmlProgressMeterDE from "../../lessons/de/24-html-progress-meter.json";
import htmlDatalistDE from "../../lessons/de/25-html-datalist.json";
import htmlDialogDE from "../../lessons/de/27-html-dialog.json";
import htmlFieldsetDE from "../../lessons/de/28-html-forms-fieldset.json";
import htmlTablesDE from "../../lessons/de/30-html-tables.json";
import htmlSvgDE from "../../lessons/de/32-html-svg.json";
import flexboxDE from "../../lessons/de/flexbox.json";
@@ -50,6 +67,10 @@ import htmlElementsPL from "../../lessons/pl/20-html-elements.json";
import htmlFormsBasicPL from "../../lessons/pl/21-html-forms-basic.json";
import htmlFormsValidationPL from "../../lessons/pl/22-html-forms-validation.json";
import htmlDetailsSummaryPL from "../../lessons/pl/23-html-details-summary.json";
import htmlProgressMeterPL from "../../lessons/pl/24-html-progress-meter.json";
import htmlDatalistPL from "../../lessons/pl/25-html-datalist.json";
import htmlDialogPL from "../../lessons/pl/27-html-dialog.json";
import htmlFieldsetPL from "../../lessons/pl/28-html-forms-fieldset.json";
import htmlTablesPL from "../../lessons/pl/30-html-tables.json";
import htmlSvgPL from "../../lessons/pl/32-html-svg.json";
import flexboxPL from "../../lessons/pl/flexbox.json";
@@ -65,6 +86,10 @@ import htmlElementsES from "../../lessons/es/20-html-elements.json";
import htmlFormsBasicES from "../../lessons/es/21-html-forms-basic.json";
import htmlFormsValidationES from "../../lessons/es/22-html-forms-validation.json";
import htmlDetailsSummaryES from "../../lessons/es/23-html-details-summary.json";
import htmlProgressMeterES from "../../lessons/es/24-html-progress-meter.json";
import htmlDatalistES from "../../lessons/es/25-html-datalist.json";
import htmlDialogES from "../../lessons/es/27-html-dialog.json";
import htmlFieldsetES from "../../lessons/es/28-html-forms-fieldset.json";
import htmlTablesES from "../../lessons/es/30-html-tables.json";
import htmlSvgES from "../../lessons/es/32-html-svg.json";
import flexboxES from "../../lessons/es/flexbox.json";
@@ -80,6 +105,10 @@ import htmlElementsAR from "../../lessons/ar/20-html-elements.json";
import htmlFormsBasicAR from "../../lessons/ar/21-html-forms-basic.json";
import htmlFormsValidationAR from "../../lessons/ar/22-html-forms-validation.json";
import htmlDetailsSummaryAR from "../../lessons/ar/23-html-details-summary.json";
import htmlProgressMeterAR from "../../lessons/ar/24-html-progress-meter.json";
import htmlDatalistAR from "../../lessons/ar/25-html-datalist.json";
import htmlDialogAR from "../../lessons/ar/27-html-dialog.json";
import htmlFieldsetAR from "../../lessons/ar/28-html-forms-fieldset.json";
import htmlTablesAR from "../../lessons/ar/30-html-tables.json";
import htmlSvgAR from "../../lessons/ar/32-html-svg.json";
import flexboxAR from "../../lessons/ar/flexbox.json";
@@ -95,6 +124,10 @@ import htmlElementsUK from "../../lessons/uk/20-html-elements.json";
import htmlFormsBasicUK from "../../lessons/uk/21-html-forms-basic.json";
import htmlFormsValidationUK from "../../lessons/uk/22-html-forms-validation.json";
import htmlDetailsSummaryUK from "../../lessons/uk/23-html-details-summary.json";
import htmlProgressMeterUK from "../../lessons/uk/24-html-progress-meter.json";
import htmlDatalistUK from "../../lessons/uk/25-html-datalist.json";
import htmlDialogUK from "../../lessons/uk/27-html-dialog.json";
import htmlFieldsetUK from "../../lessons/uk/28-html-forms-fieldset.json";
import htmlTablesUK from "../../lessons/uk/30-html-tables.json";
import htmlSvgUK from "../../lessons/uk/32-html-svg.json";
import flexboxUK from "../../lessons/uk/flexbox.json";
@@ -106,24 +139,39 @@ const moduleStoreEN = [
// CSS Visual (immediate impact)
basicSelectorsEN,
colorsEN,
gradientsEN,
typographyEN,
boxModelEN,
// CSS Layout
flexboxEN,
gridEN,
positioningEN,
unitsVariablesEN,
responsiveEN,
// CSS Polish
transitionsAnimationsEN,
filtersEN,
pseudoElementsEN,
// HTML Structure
htmlElementsEN,
htmlSemanticEN,
htmlFigureEN,
htmlSvgEN,
// HTML Interactive
htmlDetailsSummaryEN,
htmlDialogEN,
htmlProgressMeterEN,
htmlFormsBasicEN,
htmlFormsValidationEN,
htmlFieldsetEN,
htmlDatalistEN,
htmlTablesEN,
// Markdown
markdownBasicsEN,
// JavaScript
jsVariablesEN,
jsDomEN,
jsEventsEN,
// Outro
goodbyeEN,
playgroundEN
@@ -136,24 +184,39 @@ const moduleStoreDE = [
// CSS Visual (immediate impact)
basicSelectorsDE,
colorsEN, // Using EN fallback until translated
gradientsEN, // Using EN fallback until translated
typographyEN, // Using EN fallback until translated
boxModelDE,
// CSS Layout
flexboxDE,
gridEN, // Using EN fallback until translated
positioningEN, // Using EN fallback until translated
unitsVariablesDE,
responsiveDE,
// CSS Polish
transitionsAnimationsDE,
filtersEN, // Using EN fallback until translated
pseudoElementsEN, // Using EN fallback until translated
// HTML Structure
htmlElementsDE,
htmlSemanticEN, // Using EN fallback until translated
htmlFigureEN, // Using EN fallback until translated
htmlSvgDE,
// HTML Interactive
htmlDetailsSummaryDE,
htmlDialogDE,
htmlProgressMeterDE,
htmlFormsBasicDE,
htmlFormsValidationDE,
htmlFieldsetDE,
htmlDatalistDE,
htmlTablesDE,
// Markdown
markdownBasicsEN, // Using EN fallback until translated
// JavaScript
jsVariablesEN, // Using EN fallback until translated
jsDomEN, // Using EN fallback until translated
jsEventsEN, // Using EN fallback until translated
// Outro
goodbyeEN,
playgroundEN
@@ -166,24 +229,39 @@ const moduleStorePL = [
// CSS Visual (immediate impact)
basicSelectorsPL,
colorsEN, // Using EN fallback until translated
gradientsEN, // Using EN fallback until translated
typographyEN, // Using EN fallback until translated
boxModelPL,
// CSS Layout
flexboxPL,
gridEN, // Using EN fallback until translated
positioningEN, // Using EN fallback until translated
unitsVariablesPL,
responsivePL,
// CSS Polish
transitionsAnimationsPL,
filtersEN, // Using EN fallback until translated
pseudoElementsEN, // Using EN fallback until translated
// HTML Structure
htmlElementsPL,
htmlSemanticEN, // Using EN fallback until translated
htmlFigureEN, // Using EN fallback until translated
htmlSvgPL,
// HTML Interactive
htmlDetailsSummaryPL,
htmlDialogPL,
htmlProgressMeterPL,
htmlFormsBasicPL,
htmlFormsValidationPL,
htmlFieldsetPL,
htmlDatalistPL,
htmlTablesPL,
// Markdown
markdownBasicsEN, // Using EN fallback until translated
// JavaScript
jsVariablesEN, // Using EN fallback until translated
jsDomEN, // Using EN fallback until translated
jsEventsEN, // Using EN fallback until translated
// Outro
goodbyeEN,
playgroundEN
@@ -196,24 +274,39 @@ const moduleStoreES = [
// CSS Visual (immediate impact)
basicSelectorsES,
colorsEN, // Using EN fallback until translated
gradientsEN, // Using EN fallback until translated
typographyEN, // Using EN fallback until translated
boxModelES,
// CSS Layout
flexboxES,
gridEN, // Using EN fallback until translated
positioningEN, // Using EN fallback until translated
unitsVariablesES,
responsiveES,
// CSS Polish
transitionsAnimationsES,
filtersEN, // Using EN fallback until translated
pseudoElementsEN, // Using EN fallback until translated
// HTML Structure
htmlElementsES,
htmlSemanticEN, // Using EN fallback until translated
htmlFigureEN, // Using EN fallback until translated
htmlSvgES,
// HTML Interactive
htmlDetailsSummaryES,
htmlDialogES,
htmlProgressMeterES,
htmlFormsBasicES,
htmlFormsValidationES,
htmlFieldsetES,
htmlDatalistES,
htmlTablesES,
// Markdown
markdownBasicsEN, // Using EN fallback until translated
// JavaScript
jsVariablesEN, // Using EN fallback until translated
jsDomEN, // Using EN fallback until translated
jsEventsEN, // Using EN fallback until translated
// Outro
goodbyeEN,
playgroundEN
@@ -226,24 +319,39 @@ const moduleStoreAR = [
// CSS Visual (immediate impact)
basicSelectorsAR,
colorsEN, // Using EN fallback until translated
gradientsEN, // Using EN fallback until translated
typographyEN, // Using EN fallback until translated
boxModelAR,
// CSS Layout
flexboxAR,
gridEN, // Using EN fallback until translated
positioningEN, // Using EN fallback until translated
unitsVariablesAR,
responsiveAR,
// CSS Polish
transitionsAnimationsAR,
filtersEN, // Using EN fallback until translated
pseudoElementsEN, // Using EN fallback until translated
// HTML Structure
htmlElementsAR,
htmlSemanticEN, // Using EN fallback until translated
htmlFigureEN, // Using EN fallback until translated
htmlSvgAR,
// HTML Interactive
htmlDetailsSummaryAR,
htmlDialogAR,
htmlProgressMeterAR,
htmlFormsBasicAR,
htmlFormsValidationAR,
htmlFieldsetAR,
htmlDatalistAR,
htmlTablesAR,
// Markdown
markdownBasicsEN, // Using EN fallback until translated
// JavaScript
jsVariablesEN, // Using EN fallback until translated
jsDomEN, // Using EN fallback until translated
jsEventsEN, // Using EN fallback until translated
// Outro
goodbyeEN,
playgroundEN
@@ -256,24 +364,39 @@ const moduleStoreUK = [
// CSS Visual (immediate impact)
basicSelectorsUK,
colorsEN, // Using EN fallback until translated
gradientsEN, // Using EN fallback until translated
typographyEN, // Using EN fallback until translated
boxModelUK,
// CSS Layout
flexboxUK,
gridEN, // Using EN fallback until translated
positioningEN, // Using EN fallback until translated
unitsVariablesUK,
responsiveUK,
// CSS Polish
transitionsAnimationsUK,
filtersEN, // Using EN fallback until translated
pseudoElementsEN, // Using EN fallback until translated
// HTML Structure
htmlElementsUK,
htmlSemanticEN, // Using EN fallback until translated
htmlFigureEN, // Using EN fallback until translated
htmlSvgUK,
// HTML Interactive
htmlDetailsSummaryUK,
htmlDialogUK,
htmlProgressMeterUK,
htmlFormsBasicUK,
htmlFormsValidationUK,
htmlFieldsetUK,
htmlDatalistUK,
htmlTablesUK,
// Markdown
markdownBasicsEN, // Using EN fallback until translated
// JavaScript
jsVariablesEN, // Using EN fallback until translated
jsDomEN, // Using EN fallback until translated
jsEventsEN, // Using EN fallback until translated
// Outro
goodbyeEN,
playgroundEN

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -3,6 +3,21 @@
* Single source of truth for lesson state and progress
*/
import { validateUserCode } from "../helpers/validator.js";
import { marked } from "marked";
// Auth sync - lazy loaded to avoid circular dependencies
let authModule = null;
async function getAuthModule() {
if (!authModule) {
try {
authModule = await import("../auth.js");
} catch (e) {
// Auth module not available, skip cloud sync
return null;
}
}
return authModule;
}
export class LessonEngine {
constructor() {
@@ -201,18 +216,18 @@ export class LessonEngine {
iframe.style.height = "100%";
iframe.style.border = "none";
iframe.title = "Preview";
iframe.setAttribute("sandbox", "allow-scripts");
const container = document.getElementById(previewContainer || "preview-area");
container.innerHTML = "";
container.appendChild(iframe);
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
iframeDoc.open();
let html;
if (mode === "html" || mode === "playground") {
// For HTML/playground mode, user code IS the HTML content (may include <style> blocks)
const userHtml = this.userCode || "";
iframeDoc.write(`
html = `
<!DOCTYPE html>
<html>
<head>
@@ -224,11 +239,11 @@ export class LessonEngine {
${userHtml}
</body>
</html>
`);
`;
} else if (mode === "tailwind") {
// For Tailwind mode, user code goes directly in HTML classes
const htmlWithClasses = this.injectTailwindClasses(previewHTML, this.userCode);
iframeDoc.write(`
html = `
<!DOCTYPE html>
<html>
<head>
@@ -241,11 +256,67 @@ export class LessonEngine {
${htmlWithClasses}
</body>
</html>
`);
`;
} else if (mode === "javascript") {
// For JavaScript mode, user code runs as a script against previewHTML
const { codePrefix, codeSuffix } = this.currentLesson;
const fullScript = `${codePrefix || ""}${this.userCode || ""}${codeSuffix || ""}`;
html = `
<!DOCTYPE html>
<html>
<head>
<style>html, body { min-height: 100%; margin: 0; }</style>
<style>${previewBaseCSS || ""}</style>
<style>${sandboxCSS || ""}</style>
</head>
<body>
${previewHTML || ""}
<script>
try {
${fullScript}
} catch (e) {
document.body.innerHTML += '<pre style="color:red">' + e.message + '</pre>';
}
</script>
</body>
</html>
`;
} else if (mode === "markdown") {
// For Markdown mode, parse user code to HTML
const renderedHtml = marked.parse(this.userCode || "");
html = `
<!DOCTYPE html>
<html>
<head>
<style>html, body { min-height: 100%; margin: 0; }</style>
<style>${previewBaseCSS || ""}</style>
<style>
body { font-family: system-ui, sans-serif; line-height: 1.6; padding: 1rem; }
h1, h2, h3, h4, h5, h6 { margin: 1em 0 0.5em; line-height: 1.3; }
h1 { font-size: 2em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
h2 { font-size: 1.5em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
p { margin: 0.5em 0; }
ul, ol { margin: 0.5em 0; padding-left: 2em; }
li { margin: 0.25em 0; }
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-family: monospace; }
pre { background: #f4f4f4; padding: 1em; overflow-x: auto; border-radius: 4px; }
pre code { background: none; padding: 0; }
blockquote { margin: 0.5em 0; padding-left: 1em; border-left: 4px solid #ddd; color: #666; }
a { color: #0366d6; }
hr { border: none; border-top: 1px solid #eee; margin: 1em 0; }
img { max-width: 100%; }
</style>
<style>${sandboxCSS || ""}</style>
</head>
<body>
${renderedHtml}
</body>
</html>
`;
} else {
// Original CSS mode
const userCssWithWrapper = this.getCompleteCss();
iframeDoc.write(`
html = `
<!DOCTYPE html>
<html>
<head>
@@ -258,10 +329,10 @@ export class LessonEngine {
${previewHTML}
</body>
</html>
`);
`;
}
iframeDoc.close();
iframe.srcdoc = html;
}
injectTailwindClasses(html, userClasses) {
@@ -294,6 +365,7 @@ export class LessonEngine {
iframe.style.height = "100%";
iframe.style.border = "none";
iframe.title = "Expected Result";
iframe.setAttribute("sandbox", "allow-scripts");
const container = document.getElementById("preview-expected");
if (!container) return;
@@ -301,12 +373,11 @@ export class LessonEngine {
container.innerHTML = "";
container.appendChild(iframe);
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
iframeDoc.open();
let html;
if (mode === "html" || mode === "playground") {
// For HTML/playground mode, solution code IS the HTML content
iframeDoc.write(`
html = `
<!DOCTYPE html>
<html>
<head>
@@ -318,11 +389,11 @@ export class LessonEngine {
${solutionCode}
</body>
</html>
`);
`;
} else if (mode === "tailwind") {
// For Tailwind mode, inject solution classes into HTML
const htmlWithClasses = this.injectTailwindClasses(previewHTML, solutionCode);
iframeDoc.write(`
html = `
<!DOCTYPE html>
<html>
<head>
@@ -335,12 +406,68 @@ export class LessonEngine {
${htmlWithClasses}
</body>
</html>
`);
`;
} else if (mode === "javascript") {
// For JavaScript mode, solution code runs as a script against previewHTML
const { codePrefix, codeSuffix } = this.currentLesson;
const fullScript = `${codePrefix || ""}${solutionCode}${codeSuffix || ""}`;
html = `
<!DOCTYPE html>
<html>
<head>
<style>html, body { min-height: 100%; margin: 0; }</style>
<style>${previewBaseCSS || ""}</style>
<style>${sandboxCSS || ""}</style>
</head>
<body>
${previewHTML || ""}
<script>
try {
${fullScript}
} catch (e) {
document.body.innerHTML += '<pre style="color:red">' + e.message + '</pre>';
}
</script>
</body>
</html>
`;
} else if (mode === "markdown") {
// For Markdown mode, parse solution to HTML
const renderedHtml = marked.parse(solutionCode || "");
html = `
<!DOCTYPE html>
<html>
<head>
<style>html, body { min-height: 100%; margin: 0; }</style>
<style>${previewBaseCSS || ""}</style>
<style>
body { font-family: system-ui, sans-serif; line-height: 1.6; padding: 1rem; }
h1, h2, h3, h4, h5, h6 { margin: 1em 0 0.5em; line-height: 1.3; }
h1 { font-size: 2em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
h2 { font-size: 1.5em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
p { margin: 0.5em 0; }
ul, ol { margin: 0.5em 0; padding-left: 2em; }
li { margin: 0.25em 0; }
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-family: monospace; }
pre { background: #f4f4f4; padding: 1em; overflow-x: auto; border-radius: 4px; }
pre code { background: none; padding: 0; }
blockquote { margin: 0.5em 0; padding-left: 1em; border-left: 4px solid #ddd; color: #666; }
a { color: #0366d6; }
hr { border: none; border-top: 1px solid #eee; margin: 1em 0; }
img { max-width: 100%; }
</style>
<style>${sandboxCSS || ""}</style>
</head>
<body>
${renderedHtml}
</body>
</html>
`;
} else {
// CSS mode - wrap solution with prefix/suffix
const { codePrefix, codeSuffix } = this.currentLesson;
const solutionCss = `${codePrefix || ""}${solutionCode}${codeSuffix || ""}`;
iframeDoc.write(`
html = `
<!DOCTYPE html>
<html>
<head>
@@ -353,10 +480,10 @@ export class LessonEngine {
${previewHTML}
</body>
</html>
`);
`;
}
iframeDoc.close();
iframe.srcdoc = html;
}
/**
@@ -458,10 +585,11 @@ export class LessonEngine {
}
/**
* Get overall progress statistics
* @returns {Object} Progress statistics
* Get overall progress statistics with milestone data
* @returns {Object} Progress statistics including milestone progress
*/
getProgressStats() {
const MILESTONES = [1, 5, 10, 20, 30, 50, 75, 100];
let totalLessons = 0;
let totalCompleted = 0;
@@ -476,15 +604,30 @@ export class LessonEngine {
}
});
// Calculate milestone progress
const milestonesReached = MILESTONES.filter((m) => totalCompleted >= m);
const currentMilestone = milestonesReached[milestonesReached.length - 1] || 0;
const nextMilestone = MILESTONES.find((m) => m > totalCompleted) || 100;
const progressToNext =
nextMilestone > currentMilestone
? Math.round(((totalCompleted - currentMilestone) / (nextMilestone - currentMilestone)) * 100)
: 100;
return {
totalLessons,
totalCompleted,
percentComplete: totalLessons > 0 ? Math.round((totalCompleted / totalLessons) * 100) : 0
percentComplete: totalLessons > 0 ? Math.round((totalCompleted / totalLessons) * 100) : 0,
// Milestone data
milestones: MILESTONES,
milestonesReached,
currentMilestone,
nextMilestone,
progressToNext
};
}
/**
* Save progress to localStorage
* Save progress to localStorage and optionally sync to cloud
*/
saveUserProgress() {
try {
@@ -494,11 +637,24 @@ export class LessonEngine {
timestamp: new Date().toISOString()
};
localStorage.setItem("codeCrispies.progress", JSON.stringify(progressData));
// Trigger cloud sync if logged in (debounced)
this.triggerCloudSync();
} catch (e) {
console.error("Error saving progress:", e);
}
}
/**
* Trigger cloud sync if user is logged in (debounced)
*/
async triggerCloudSync() {
const auth = await getAuthModule();
if (auth?.isLoggedIn()) {
auth.debouncedSyncToCloud();
}
}
/**
* Load progress from localStorage
*/
@@ -521,11 +677,14 @@ export class LessonEngine {
}
/**
* Save user code to localStorage
* Save user code to localStorage and optionally sync to cloud
*/
saveUserCodeToStorage() {
try {
localStorage.setItem("codeCrispies.userCode", JSON.stringify(Array.from(this.userCodeMap.entries())));
// Trigger cloud sync if logged in (debounced)
this.triggerCloudSync();
} catch (e) {
console.error("Error saving user code:", e);
}

View File

@@ -4,9 +4,10 @@
<meta charset="UTF-8" />
<link rel="icon" href="./favicon.ico" type="image/x-icon" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' https://librete.ch https://liberapay.com; style-src 'self' 'unsafe-inline'; connect-src 'self' https://*.supabase.co wss://*.supabase.co; img-src 'self' https://liberapay.com data:; font-src 'self'; frame-src 'self' blob:" />
<!-- Primary Meta Tags -->
<title>Code Crispies - Learn HTML & CSS Interactively | Free Coding Practice</title>
<title>CODE CRISPIES - Learn HTML & CSS Interactively | Free Coding Practice</title>
<meta
name="description"
content="Master HTML, CSS, and Tailwind through hands-on coding exercises. Free, open-source learning platform with instant feedback. No account required."
@@ -19,14 +20,14 @@
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://codecrispi.es/" />
<meta property="og:title" content="Code Crispies - Learn HTML & CSS Interactively" />
<meta property="og:title" content="CODE CRISPIES - Learn HTML & CSS Interactively" />
<meta property="og:description" content="Master HTML, CSS, and Tailwind through hands-on coding exercises. Free and open source." />
<meta property="og:image" content="https://codecrispi.es/og-image.png" />
<meta property="og:site_name" content="Code Crispies" />
<meta property="og:site_name" content="CODE CRISPIES" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Code Crispies - Learn HTML & CSS Interactively" />
<meta name="twitter:title" content="CODE CRISPIES - Learn HTML & CSS Interactively" />
<meta name="twitter:description" content="Master HTML, CSS, and Tailwind through hands-on coding exercises." />
<meta name="twitter:image" content="https://codecrispi.es/og-image.png" />
@@ -35,7 +36,7 @@
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Code Crispies",
"name": "CODE CRISPIES",
"description": "Interactive platform for learning HTML, CSS, and Tailwind through hands-on coding exercises",
"url": "https://codecrispi.es/",
"applicationCategory": "EducationalApplication",
@@ -74,9 +75,13 @@
<nav class="main-nav" id="main-nav" aria-label="Main sections">
<a href="#css" class="nav-link" data-section="css">CSS</a>
<a href="#html" class="nav-link" data-section="html">HTML</a>
<a href="#tailwind" class="nav-link" data-section="tailwind">Tailwind</a>
<!-- <a href="#tailwind" class="nav-link" data-section="tailwind">Tailwind</a> -->
<a href="#markdown" class="nav-link" data-section="markdown">Markdown</a>
<a href="#javascript" class="nav-link" data-section="javascript">JavaScript</a>
<a href="#reference/css" class="nav-link nav-link-ref" data-section="reference">Reference</a>
</nav>
<button id="auth-trigger-header" class="btn btn-outline btn-sm" data-i18n="authLogin">Log In</button>
<span id="user-email-header" class="user-email hidden"></span>
<button id="help-btn" class="help-toggle" data-i18n-aria-label="help" aria-label="Help">?</button>
</div>
</header>
@@ -100,7 +105,7 @@
</section>
<section class="why-it-works">
<h2 data-i18n="landingWhyTitle">Why Code Crispies Works</h2>
<h2 data-i18n="landingWhyTitle">Why CODE CRISPIES Works</h2>
<div class="benefits-grid">
<article class="benefit-card">
<svg class="benefit-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -139,7 +144,7 @@
</svg>
<h3 data-i18n="landingBenefit4Title">Free & Open Source</h3>
<p data-i18n="landingBenefit4Text">
No account, no paywall, no tracking. Your progress stays in your browser. The code is open for everyone.
No paywall, no tracking. Optional account for cloud sync across devices. The code is open for everyone.
</p>
</article>
</div>
@@ -160,18 +165,71 @@
<p data-i18n="landingHtmlDesc">Semantic markup and native elements</p>
<span class="section-card-progress" id="html-progress"></span>
</a>
<!-- Tailwind temporarily disabled
<a href="#tailwind" class="section-card" data-section="tailwind">
<div class="section-card-icon" style="background: #26a69a">TW</div>
<h3>Tailwind CSS</h3>
<p data-i18n="landingTailwindDesc">Utility-first CSS framework</p>
<span class="section-card-progress" id="tailwind-progress"></span>
</a>
-->
<a href="#markdown" class="section-card" data-section="markdown">
<div class="section-card-icon" style="background: #5b8dd9">MD</div>
<h3>Markdown</h3>
<p data-i18n="landingMarkdownDesc">Lightweight markup for formatting text</p>
<span class="section-card-progress" id="markdown-progress"></span>
</a>
<a href="#javascript" class="section-card" data-section="javascript">
<div class="section-card-icon" style="background: #f0db4f; color: #333">JS</div>
<h3>JavaScript</h3>
<p data-i18n="landingJsDesc">Interactive scripting for web pages</p>
<span class="section-card-progress" id="javascript-progress"></span>
</a>
</div>
<p class="device-notice" data-i18n-html="deviceNotice">
<strong>Best on desktop or tablet (landscape).</strong> Mobile works, but larger screens make coding easier.
</p>
</section>
<section class="coming-soon">
<h2 data-i18n="landingComingSoonTitle">Coming Soon</h2>
<div class="coming-soon-grid">
<article class="coming-soon-card">
<span class="coming-soon-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/></svg>
</span>
<h3 data-i18n="comingSoonAchievementsTitle">Achievements</h3>
<p data-i18n="comingSoonAchievementsText">Earn badges as you master new skills. Track your learning milestones.</p>
</article>
<article class="coming-soon-card">
<span class="coming-soon-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>
</span>
<h3 data-i18n="comingSoonFrameworksTitle">Frameworks</h3>
<p data-i18n="comingSoonFrameworksText">React, Vue, and Svelte basics. Build real components step by step.</p>
</article>
<article class="coming-soon-card">
<span class="coming-soon-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
</span>
<h3 data-i18n="comingSoonChallengesTitle">Code Challenges</h3>
<p data-i18n="comingSoonChallengesText">Test your skills with timed puzzles. Compete on leaderboards and earn ranks.</p>
</article>
</div>
<div class="newsletter-signup">
<p data-i18n="newsletterText">Want to know when new features launch?</p>
<form id="newsletter-form" class="newsletter-form">
<input type="email" id="newsletter-email" required data-i18n-placeholder="newsletterPlaceholder" placeholder="your@email.com">
<button type="submit" class="btn btn-outline" data-i18n="newsletterButton">Notify Me</button>
</form>
<p class="newsletter-disclaimer" data-i18n="newsletterDisclaimer">Max once a week. Unsubscribe anytime via mail@codecrispi.es</p>
<p id="newsletter-thanks" class="newsletter-thanks hidden" data-i18n="newsletterThanks">Thanks! We'll keep you posted.</p>
</div>
</section>
<section class="landing-cta">
<h2 data-i18n="landingCtaTitle">Start Learning Today</h2>
<a href="#welcome/0" class="cta-button" data-i18n="landingCtaButton">Begin Your Journey</a>
<a href="#welcome/0" class="cta-button" data-i18n="landingCtaButton">Let's get crispy!</a>
<p class="cta-sub" data-i18n="landingCtaSub">Free and open source. No account required. Progress saved locally.</p>
</section>
@@ -198,13 +256,18 @@
</section>
<section class="footer-section footer-support">
<h4 data-i18n="footerSupport">Support</h4>
<p data-i18n="footerSupportText">Help keep Code Crispies free and open source.</p>
<p data-i18n="footerSupportText">Help keep CODE CRISPIES free and open source.</p>
<script src="https://liberapay.com/libretech/widgets/button.js"></script>
<noscript><a href="https://liberapay.com/libretech/donate"><img alt="Donate using Liberapay" src="https://liberapay.com/assets/widgets/donate.svg"></a></noscript>
</section>
</div>
<div class="footer-bottom">
<p>&copy; 2025 <a href="https://librete.ch">LibreTECH</a>. <span data-i18n="footerLicense">Open source under MIT License.</span></p>
<p>&copy; <span class="current-year"></span> <a href="https://librete.ch">LibreTECH</a>. <span data-i18n="footerLicense">Open source under MIT License.</span></p>
<p class="footer-legal">
<button type="button" class="btn-text privacy-link" data-i18n="footerPrivacy">Privacy Policy</button>
<span class="footer-separator">·</span>
<button type="button" class="btn-text imprint-link" data-i18n="footerImprint">Imprint</button>
</p>
</div>
</footer>
</div>
@@ -227,6 +290,43 @@
<!-- Educational content with integrated module links -->
<div class="section-intro" id="section-intro"></div>
</article>
<footer class="section-footer landing-footer">
<div class="footer-grid">
<section class="footer-section footer-modules">
<div id="section-footer-lesson-links" class="footer-links"></div>
</section>
<section class="footer-section">
<h4 data-i18n="footerResources">Resources</h4>
<ul class="footer-links">
<li><a href="#reference/css">CSS Reference</a></li>
<li><a href="#reference/html">HTML Reference</a></li>
<li><a href="#playground/0" data-i18n="footerPlayground">Playground</a></li>
</ul>
</section>
<section class="footer-section">
<h4 data-i18n="footerAbout">About</h4>
<ul class="footer-links">
<li><a href="https://librete.ch" target="_blank">LibreTECH</a></li>
<li><a href="https://git.librete.ch/libretech/code-crispies" target="_blank">Source Code</a></li>
<li><a href="https://github.com/nextlevelshit/code-crispies" target="_blank">GitHub</a></li>
</ul>
</section>
<section class="footer-section footer-support">
<h4 data-i18n="footerSupport">Support</h4>
<p data-i18n="footerSupportText">Help keep CODE CRISPIES free and open source.</p>
<script src="https://liberapay.com/libretech/widgets/button.js"></script>
<noscript><a href="https://liberapay.com/libretech/donate"><img alt="Donate using Liberapay" src="https://liberapay.com/assets/widgets/donate.svg"></a></noscript>
</section>
</div>
<div class="footer-bottom">
<p>&copy; <span class="current-year"></span> <a href="https://librete.ch">LibreTECH</a>. <span data-i18n="footerLicense">Open source under MIT License.</span></p>
<p class="footer-legal">
<button type="button" class="btn-text privacy-link" data-i18n="footerPrivacy">Privacy Policy</button>
<span class="footer-separator">·</span>
<button type="button" class="btn-text imprint-link" data-i18n="footerImprint">Imprint</button>
</p>
</div>
</footer>
</div>
<!-- Reference/Cheatsheet Pages -->
@@ -238,13 +338,48 @@
<a href="#reference/flexbox" class="ref-nav-link" data-ref="flexbox">Flexbox</a>
<a href="#reference/grid" class="ref-nav-link" data-ref="grid">Grid</a>
<a href="#reference/html" class="ref-nav-link" data-ref="html">HTML Elements</a>
<a href="#reference/markdown" class="ref-nav-link" data-ref="markdown">Markdown</a>
</nav>
<div class="reference-body" id="reference-body">
<!-- Reference content injected by JS -->
</div>
</article>
<footer class="reference-footer">
<p>&copy; 2025 <a href="https://librete.ch">LibreTECH</a>. <span data-i18n="footerLicense">Released into public domain.</span></p>
<footer class="reference-footer landing-footer">
<div class="footer-grid">
<section class="footer-section footer-modules">
<div id="ref-footer-lesson-links" class="footer-links"></div>
</section>
<section class="footer-section">
<h4 data-i18n="footerResources">Resources</h4>
<ul class="footer-links">
<li><a href="#reference/css">CSS Reference</a></li>
<li><a href="#reference/html">HTML Reference</a></li>
<li><a href="#playground/0" data-i18n="footerPlayground">Playground</a></li>
</ul>
</section>
<section class="footer-section">
<h4 data-i18n="footerAbout">About</h4>
<ul class="footer-links">
<li><a href="https://librete.ch" target="_blank">LibreTECH</a></li>
<li><a href="https://git.librete.ch/libretech/code-crispies" target="_blank">Source Code</a></li>
<li><a href="https://github.com/nextlevelshit/code-crispies" target="_blank">GitHub</a></li>
</ul>
</section>
<section class="footer-section footer-support">
<h4 data-i18n="footerSupport">Support</h4>
<p data-i18n="footerSupportText">Help keep CODE CRISPIES free and open source.</p>
<script src="https://liberapay.com/libretech/widgets/button.js"></script>
<noscript><a href="https://liberapay.com/libretech/donate"><img alt="Donate using Liberapay" src="https://liberapay.com/assets/widgets/donate.svg"></a></noscript>
</section>
</div>
<div class="footer-bottom">
<p>&copy; <span class="current-year"></span> <a href="https://librete.ch">LibreTECH</a>. <span data-i18n="footerLicense">Open source under MIT License.</span></p>
<p class="footer-legal">
<button type="button" class="btn-text privacy-link" data-i18n="footerPrivacy">Privacy Policy</button>
<span class="footer-separator">·</span>
<button type="button" class="btn-text imprint-link" data-i18n="footerImprint">Imprint</button>
</p>
</div>
</footer>
</div>
@@ -281,6 +416,7 @@
<label for="code-input" class="editor-label" data-i18n="editorLabel">CSS Editor</label>
<div class="editor-actions">
<div class="editor-tools">
<button id="random-template-btn" class="btn btn-icon hidden" title="Load random template"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="20" rx="3" ry="3"/><circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="17" cy="17" r="1.5" fill="currentColor" stroke="none"/><circle cx="17" cy="7" r="1.5" fill="currentColor" stroke="none"/><circle cx="7" cy="17" r="1.5" fill="currentColor" stroke="none"/></svg></button>
<button id="undo-btn" class="btn btn-icon" data-i18n-title="undoTitle" title="Undo (Ctrl+Z)"></button>
<button id="redo-btn" class="btn btn-icon" data-i18n-title="redoTitle" title="Redo (Ctrl+Shift+Z)"></button>
<button
@@ -291,7 +427,6 @@
>
</button>
<button id="random-template-btn" class="btn btn-icon hidden" title="Load random template"><img src="./dice.svg" alt="" /></button>
</div>
<button id="run-btn" class="btn btn-run"><img src="./gear.svg" alt="" /><span data-i18n="run">Run</span></button>
</div>
@@ -339,40 +474,76 @@
<button id="close-sidebar" class="close-btn" data-i18n-aria-label="closeMenu" aria-label="Close menu">&times;</button>
</div>
<nav class="sidebar-section sidebar-nav-mobile" aria-label="Learning paths">
<a href="#css" class="sidebar-nav-link" data-section="css">CSS</a>
<a href="#html" class="sidebar-nav-link" data-section="html">HTML</a>
<!-- <a href="#tailwind" class="sidebar-nav-link" data-section="tailwind">Tailwind</a> -->
<a href="#javascript" class="sidebar-nav-link" data-section="javascript">JavaScript</a>
<button id="auth-trigger-mobile" class="sidebar-nav-link sidebar-auth-link" data-i18n="authLogin">Log In</button>
</nav>
<div class="sidebar-section">
<h4 data-i18n="progress">Progress</h4>
<div class="progress-display" id="progress-display">
<div class="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
<div class="progress-display milestone-progress" id="progress-display">
<div class="milestones" id="milestones">
<span class="milestone" data-value="1">1</span>
<span class="milestone" data-value="5">5</span>
<span class="milestone" data-value="10">10</span>
<span class="milestone" data-value="20">20</span>
<span class="milestone" data-value="30">30</span>
<span class="milestone" data-value="50">50</span>
<span class="milestone" data-value="75">75</span>
<span class="milestone" data-value="100">100</span>
</div>
<span class="progress-text" id="progress-text">0% Complete</span>
<div class="progress-bar-row">
<div class="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
</div>
<span class="progress-current" id="progress-current">0/1</span>
</div>
<span class="progress-total" id="progress-total">0 of 100 lessons</span>
</div>
</div>
<nav class="sidebar-section" aria-label="Lesson navigation">
<h4 id="lessons-heading" data-i18n="lessons">Lessons</h4>
<div class="module-list" id="module-list" role="tree" aria-labelledby="lessons-heading"></div>
<div class="sidebar-auth-box">
<h4 data-i18n="authAccount">Account</h4>
<button id="auth-trigger-sidebar" class="btn btn-outline btn-full" data-i18n="authLogin">Log In</button>
<div id="user-menu-sidebar" class="user-menu-sidebar hidden">
<span id="user-email-sidebar" class="user-email"></span>
<button id="logout-btn-sidebar" class="btn btn-outline btn-full" data-i18n="authLogout">Log Out</button>
<button id="delete-account-btn" class="btn btn-text btn-danger btn-full" data-i18n="authDeleteAccount">Delete Account</button>
</div>
<p class="sidebar-auth-hint" data-i18n="authSyncHint">Log in to sync progress across devices</p>
</div>
</nav>
<div class="sidebar-section">
<h4 data-i18n="settings">Settings</h4>
<label class="setting-row">
<span class="setting-label" data-i18n="language">Language</span>
<select id="lang-select" class="lang-select">
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="pl">Polski</option>
<option value="es">Español</option>
<option value="ar">العربية</option>
<option value="uk">Українська</option>
</select>
</label>
<label class="toggle-switch">
<input type="checkbox" id="disable-feedback-toggle" checked />
<span class="toggle-slider"></span>
<span class="toggle-label" data-i18n="showHints">Show Hints</span>
</label>
<button id="reset-btn" class="btn btn-text" data-i18n="resetAllProgress">Reset All Progress</button>
<div class="settings-card">
<label class="settings-row">
<span class="settings-label" data-i18n="language">Language</span>
<select id="lang-select" class="lang-select">
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="pl">Polski</option>
<option value="es">Español</option>
<option value="ar">العربية</option>
<option value="uk">Українська</option>
</select>
</label>
<label class="settings-row">
<span class="settings-label" data-i18n="showHints">Show Hints</span>
<input type="checkbox" id="disable-feedback-toggle" class="settings-toggle" checked />
</label>
<div class="settings-row">
<span class="settings-label" data-i18n="resetAllProgress">Reset All Progress</span>
<button id="reset-btn" class="btn btn-sm btn-ghost" data-i18n="reset">Reset</button>
</div>
</div>
</div>
<footer class="app-footer">
@@ -389,9 +560,9 @@
<button id="help-dialog-close" class="dialog-close" aria-label="Close">&times;</button>
</div>
<div class="dialog-content">
<h4 data-i18n="aboutTitle">About Code Crispies</h4>
<h4 data-i18n="aboutTitle">About CODE CRISPIES</h4>
<p data-i18n="aboutText">
Code Crispies is a free, open-source platform for learning web development through hands-on exercises. No account required -
CODE CRISPIES is a free, open-source platform for learning web development through hands-on exercises. No account required -
just start coding!
</p>
@@ -455,7 +626,7 @@
</div>
<h4 data-i18n="contactTitle">Contact & Links</h4>
<p data-i18n-html="contactText">Code Crispies is developed by <a href="https://librete.ch" target="_blank">LibreTECH</a></p>
<p data-i18n-html="contactText">CODE CRISPIES is developed by <a href="https://librete.ch" target="_blank">LibreTECH</a></p>
<ul>
<li><a href="https://git.librete.ch/libretech/code-crispies" target="_blank">Gitea</a> Self-hosted source repository</li>
<li><a href="https://github.com/nextlevelshit/code-crispies" target="_blank">GitHub</a> Public mirror</li>
@@ -463,7 +634,7 @@
</ul>
<h4 data-i18n="supportTitle">Support the Project</h4>
<p data-i18n="supportText">Help keep Code Crispies free and open source.</p>
<p data-i18n="supportText">Help keep CODE CRISPIES free and open source.</p>
<div class="help-support" onclick="typeof umami !== 'undefined' && umami.track('support_click', {location: 'help'})">
<script src="https://liberapay.com/libretech/widgets/button.js"></script>
<noscript><a href="https://liberapay.com/libretech/donate"><img alt="Donate using Liberapay" src="https://liberapay.com/assets/widgets/donate.svg"></a></noscript>
@@ -506,6 +677,22 @@
</div>
</dialog>
<!-- Delete Account Confirmation Dialog -->
<dialog id="delete-account-dialog" class="dialog">
<div class="dialog-header">
<h3 data-i18n="authDeleteDialogTitle">Delete Account</h3>
<button id="delete-dialog-close" class="dialog-close" aria-label="Close">&times;</button>
</div>
<div class="dialog-content">
<p data-i18n="authDeleteDialogText">Are you sure you want to delete your account? All your cloud progress will be permanently deleted. This cannot be undone.</p>
<p id="delete-account-error" class="auth-error hidden"></p>
<div class="dialog-actions">
<button id="cancel-delete" class="btn" data-i18n="cancel">Cancel</button>
<button id="confirm-delete" class="btn btn-danger" data-i18n="authDeleteConfirm">Delete Account</button>
</div>
</div>
</dialog>
<!-- Share Dialog -->
<dialog id="share-dialog" class="dialog">
<div class="dialog-header">
@@ -521,6 +708,136 @@
<p id="copy-feedback" class="copy-feedback" data-i18n="urlCopied" hidden>URL copied to clipboard!</p>
</div>
</dialog>
<!-- Privacy Policy Dialog -->
<dialog id="privacy-dialog" class="dialog legal-dialog">
<div class="dialog-header">
<h3 data-i18n="privacyTitle">Privacy Policy</h3>
<button class="dialog-close privacy-dialog-close" aria-label="Close">&times;</button>
</div>
<div class="dialog-content legal-content">
<p data-i18n="privacyIntro">CODE CRISPIES respects your privacy. This policy explains what data we collect and how we use it.</p>
<h4 data-i18n="privacyLocalTitle">Local Storage</h4>
<p data-i18n="privacyLocalText">Your learning progress, code, and settings are stored locally in your browser. This data never leaves your device unless you create an account.</p>
<h4 data-i18n="privacyAccountTitle">Account Data (Optional)</h4>
<p data-i18n="privacyAccountText">If you create an account, we store your email address and encrypted password to enable cloud sync. Your progress data is synced to our servers (Supabase) so you can access it across devices.</p>
<h4 data-i18n="privacyNewsletterTitle">Newsletter (Optional)</h4>
<p data-i18n="privacyNewsletterText">If you subscribe to our newsletter, we store your email address to send updates about new features. You can unsubscribe anytime.</p>
<h4 data-i18n="privacyNoTrackingTitle">No Tracking</h4>
<p data-i18n="privacyNoTrackingText">We do not use cookies for tracking, analytics, or advertising. We do not share your data with third parties.</p>
<h4 data-i18n="privacyRightsTitle">Your Rights (GDPR)</h4>
<p data-i18n="privacyRightsText">You can delete your account and all associated data at any time from the sidebar menu. For questions or data requests, contact us at mail@codecrispi.es</p>
<p class="legal-updated" data-i18n="privacyUpdated">Last updated: January 2025</p>
</div>
</dialog>
<!-- Imprint Dialog -->
<dialog id="imprint-dialog" class="dialog legal-dialog">
<div class="dialog-header">
<h3 data-i18n="imprintTitle">Imprint</h3>
<button class="dialog-close imprint-dialog-close" aria-label="Close">&times;</button>
</div>
<div class="dialog-content legal-content">
<h4 data-i18n="imprintResponsibleTitle">Responsible for content</h4>
<p>
Michael Czechowski<br>
Schnellweg 3<br>
70199 Stuttgart<br>
Germany
</p>
<h4 data-i18n="imprintContactTitle">Contact</h4>
<p>
Email: mail@codecrispi.es<br>
Website: <a href="https://librete.ch" target="_blank">librete.ch</a>
</p>
<h4 data-i18n="imprintDisclaimerTitle">Disclaimer</h4>
<p data-i18n="imprintDisclaimerText">CODE CRISPIES is provided "as is" without warranty. We are not liable for any damages arising from the use of this service. External links are provided for convenience; we are not responsible for their content.</p>
</div>
</dialog>
<!-- Auth Dialog -->
<dialog id="auth-dialog" class="dialog auth-dialog">
<div class="dialog-header">
<h2 id="auth-dialog-title" data-i18n="authLogin">Log In</h2>
<button class="dialog-close close-dialog" aria-label="Close">&times;</button>
</div>
<div class="dialog-content">
<!-- Login Form -->
<form id="login-form" class="auth-form">
<div class="form-field">
<label for="login-email" data-i18n="authEmail">Email</label>
<input type="email" id="login-email" required autocomplete="email">
</div>
<div class="form-field">
<label for="login-password" data-i18n="authPassword">Password</label>
<input type="password" id="login-password" required minlength="6" autocomplete="current-password">
</div>
<p id="login-error" class="auth-error hidden"></p>
<button type="submit" class="btn btn-primary btn-full" data-i18n="authLogin">Log In</button>
</form>
<!-- Signup Form (hidden by default) -->
<form id="signup-form" class="auth-form hidden">
<div class="form-field">
<label for="signup-email" data-i18n="authEmail">Email</label>
<input type="email" id="signup-email" required autocomplete="email">
</div>
<div class="form-field">
<label for="signup-password" data-i18n="authPassword">Password</label>
<input type="password" id="signup-password" required minlength="6" autocomplete="new-password">
</div>
<div class="form-field">
<label for="signup-confirm" data-i18n="authConfirmPassword">Confirm Password</label>
<input type="password" id="signup-confirm" required minlength="6" autocomplete="new-password">
</div>
<p id="signup-error" class="auth-error hidden"></p>
<p id="signup-success" class="auth-success hidden" data-i18n="authSignupSuccess">Account created! Check your email to confirm.</p>
<button type="submit" class="btn btn-primary btn-full" data-i18n="authSignUp">Sign Up</button>
</form>
<!-- Password Reset Form (hidden by default) -->
<form id="reset-form" class="auth-form hidden">
<p class="auth-instructions" data-i18n="authResetInstructions">Enter your email to receive a password reset link.</p>
<div class="form-field">
<label for="reset-email" data-i18n="authEmail">Email</label>
<input type="email" id="reset-email" required autocomplete="email">
</div>
<p id="reset-error" class="auth-error hidden"></p>
<p id="reset-success" class="auth-success hidden" data-i18n="authResetSent">Check your email for the reset link.</p>
<button type="submit" class="btn btn-primary btn-full" data-i18n="authSendReset">Send Reset Link</button>
</form>
<!-- Social Login Buttons -->
<div class="auth-social">
<div class="auth-divider"><span data-i18n="authOrContinueWith">or continue with</span></div>
<div class="auth-social-buttons">
<button type="button" id="google-login" class="btn btn-social">
<svg class="social-icon" viewBox="0 0 24 24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/></svg>
Google
</button>
<button type="button" id="github-login" class="btn btn-social">
<svg class="social-icon" viewBox="0 0 24 24"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" fill="currentColor"/></svg>
GitHub
</button>
</div>
</div>
<!-- Form switcher links -->
<div class="auth-links">
<button type="button" id="show-signup" class="btn-text" data-i18n="authNoAccount">Don't have an account? Sign up</button>
<button type="button" id="show-login" class="btn-text hidden" data-i18n="authHaveAccount">Already have an account? Log in</button>
<button type="button" id="show-reset" class="btn-text" data-i18n="authForgotPassword">Forgot password?</button>
</div>
</div>
</dialog>
</div>
<script type="module" src="app.js"></script>

File diff suppressed because it is too large Load Diff

106
src/supabase.js Normal file
View File

@@ -0,0 +1,106 @@
import { createClient } from "@supabase/supabase-js";
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
// Check if Supabase is configured
export const isConfigured = Boolean(supabaseUrl && supabaseAnonKey);
// Only create client if configured
const supabase = isConfigured
? createClient(supabaseUrl, supabaseAnonKey)
: null;
// Auth helpers - all return null/rejected promise if not configured
export const auth = {
signUp: (email, password) =>
supabase?.auth.signUp({ email, password }) ??
Promise.resolve({ data: null, error: { message: "Not configured" } }),
signIn: (email, password) =>
supabase?.auth.signInWithPassword({ email, password }) ??
Promise.resolve({ data: null, error: { message: "Not configured" } }),
signOut: () =>
supabase?.auth.signOut() ??
Promise.resolve({ error: null }),
resetPassword: (email) =>
supabase?.auth.resetPasswordForEmail(email) ??
Promise.resolve({ data: null, error: { message: "Not configured" } }),
signInWithGoogle: () =>
supabase?.auth.signInWithOAuth({ provider: "google" }) ??
Promise.resolve({ data: null, error: { message: "Not configured" } }),
signInWithGitHub: () =>
supabase?.auth.signInWithOAuth({ provider: "github" }) ??
Promise.resolve({ data: null, error: { message: "Not configured" } }),
getUser: () =>
supabase?.auth.getUser() ??
Promise.resolve({ data: { user: null }, error: null }),
getSession: () =>
supabase?.auth.getSession() ??
Promise.resolve({ data: { session: null }, error: null }),
setSession: ({ access_token, refresh_token }) =>
supabase?.auth.setSession({ access_token, refresh_token }) ??
Promise.resolve({ data: { session: null }, error: { message: "Not configured" } }),
onAuthStateChange: (callback) =>
supabase?.auth.onAuthStateChange(callback) ?? { data: { subscription: { unsubscribe: () => {} } } },
deleteAccount: async () => {
if (!supabase) return { error: { message: "Not configured" } };
const { error } = await supabase.rpc("delete_own_account");
return { error };
},
};
// Progress sync helpers
export const progressDB = {
async load(userId) {
if (!supabase) return { data: null, error: { message: "Not configured" } };
const { data, error } = await supabase
.from("user_progress")
.select("*")
.eq("user_id", userId)
.single();
return { data, error };
},
async save(userId, progress, userCode, settings, language) {
if (!supabase) return { error: { message: "Not configured" } };
const { error } = await supabase.from("user_progress").upsert(
{
user_id: userId,
progress,
user_code: userCode,
settings,
language,
},
{ onConflict: "user_id" }
);
return { error };
},
};
// Newsletter subscription helper
export const newsletter = {
async subscribe(email) {
if (!supabase) return { error: { message: "Not configured" } };
// Use insert with ignoreDuplicates since RLS only allows INSERT
const { error } = await supabase.from("newsletter_subscribers").insert(
{
email: email.toLowerCase().trim(),
subscribed_at: new Date().toISOString(),
},
{ onConflict: "email", ignoreDuplicates: true }
);
// Ignore duplicate email errors (already subscribed)
if (error?.code === "23505") return { error: null };
return { error };
},
};

58
supabase-setup.sql Normal file
View File

@@ -0,0 +1,58 @@
-- CODE CRISPIES - Supabase Database Setup
-- Run this in Supabase Dashboard → SQL Editor → New Query
-- Drop existing objects first
DROP FUNCTION IF EXISTS delete_own_account();
DROP TABLE IF EXISTS user_progress;
DROP TABLE IF EXISTS newsletter_subscribers;
-- User progress table
CREATE TABLE user_progress (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
progress JSONB NOT NULL DEFAULT '{}',
user_code JSONB NOT NULL DEFAULT '{}',
settings JSONB NOT NULL DEFAULT '{}',
language TEXT DEFAULT 'en',
updated_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id)
);
-- Newsletter subscribers table
CREATE TABLE newsletter_subscribers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
subscribed_at TIMESTAMPTZ DEFAULT NOW()
);
-- Row Level Security
ALTER TABLE user_progress ENABLE ROW LEVEL SECURITY;
ALTER TABLE newsletter_subscribers ENABLE ROW LEVEL SECURITY;
-- Users can only access their own progress
CREATE POLICY "Users can CRUD own progress"
ON user_progress FOR ALL
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
-- Anyone can subscribe to newsletter (public insert)
CREATE POLICY "Anyone can subscribe to newsletter"
ON newsletter_subscribers FOR INSERT
WITH CHECK (true);
-- Function to delete own account (called via RPC)
CREATE OR REPLACE FUNCTION delete_own_account()
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
-- Delete user's progress (CASCADE should handle this, but be explicit)
DELETE FROM user_progress WHERE user_id = auth.uid();
-- Delete the user from auth.users
DELETE FROM auth.users WHERE id = auth.uid();
END;
$$;

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import { defineConfig } from "vite";
export default defineConfig((env) => ({
base: "/",
root: "./src",
envDir: "..",
publicDir: "../public",
build: {
outDir: "../dist",

376
wave.yaml Normal file
View File

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