From b45bb3ebab2950d378b8e7957d3fdedc15e60ead Mon Sep 17 00:00:00 2001 From: Donnivis Baker Date: Tue, 9 Jun 2026 12:12:15 -0400 Subject: [PATCH 1/5] Add ROADMAP; update docs, styles, scripts Add a new ROADMAP.md and industry-standard notes; perform broad documentation updates (README, CHANGELOG, CONTRIBUTING, CODE_OF_CONDUCT, SPONSORSHIP) and small fixes to PR/issue templates. Tidy CSS formatting and hero/styles assets, adjust JS modules and Playwright test/config, update Flutter manifests and app config, and refresh many timing JSON files and other content assets. These changes are primarily editorial, formatting, and documentation additions to clarify roadmap and improve code/style consistency. --- .github/ISSUE_TEMPLATE/bug_report.md | 8 +- .github/PULL_REQUEST_TEMPLATE.md | 1 + .github/dependabot.yml | 8 +- CHANGELOG.md | 2 + CODE_OF_CONDUCT.md | 4 + CONTRIBUTING.md | 4 + README.md | 125 +- ROADMAP.md | 104 + SPONSORSHIP.md | 105 +- css/reading.css | 4 +- css/styles.css | 248 ++- css/styles.min.css | 1317 ++++++++++++- css/writing.css | 8 +- day.html | 267 ++- flutter_app/README.md | 100 +- flutter_app/analysis_options.yaml | 26 +- .../AppIcon.appiconset/Contents.json | 238 +-- .../LaunchImage.imageset/Contents.json | 40 +- .../LaunchImage.imageset/README.md | 2 +- .../AppIcon.appiconset/Contents.json | 130 +- flutter_app/pubspec.yaml | 58 +- flutter_app/web/index.html | 40 +- index.html | 1464 +++++++++----- industry-standard.md | 287 +++ js/character-drawing.js | 335 ++-- js/content-error.js | 28 +- js/data-portability.js | 194 +- js/day-page.js | 605 +++--- js/lesson-audio-sync.js | 375 ++-- js/notifications.js | 482 ++--- js/reading-page.js | 787 ++++---- js/return-to-top.js | 28 +- js/review-page.js | 108 +- js/script.js | 366 ++-- js/script.min.js | 251 ++- js/starred-phrases.js | 50 +- js/supplementary-page.js | 479 ++--- js/sw-register.js | 22 +- js/writing-page.js | 1703 +++++++++-------- manifest.json | 116 +- package-lock.json | 146 +- package.json | 18 +- playwright.config.ts | 28 +- reading.html | 319 +-- review.html | 110 +- social_media/SOCIAL_MEDIA_GUIDE.md | 1 + supplementary.html | 232 ++- sw.js | 503 ++--- tests/e2e/smoke.spec.ts | 67 +- timing/day10_en.json | 168 +- timing/day10_zh.json | 168 +- timing/day11_en.json | 180 +- timing/day11_zh.json | 180 +- timing/day12_en.json | 156 +- timing/day12_zh.json | 156 +- timing/day13_en.json | 144 +- timing/day13_zh.json | 144 +- timing/day14_en.json | 144 +- timing/day14_zh.json | 144 +- timing/day15_en.json | 204 +- timing/day15_zh.json | 204 +- timing/day16_en.json | 132 +- timing/day16_zh.json | 132 +- timing/day17_en.json | 132 +- timing/day17_zh.json | 132 +- timing/day18_en.json | 144 +- timing/day18_zh.json | 144 +- timing/day19_en.json | 168 +- timing/day19_zh.json | 168 +- timing/day1_en.json | 168 +- timing/day1_zh.json | 168 +- timing/day20_en.json | 156 +- timing/day20_zh.json | 156 +- timing/day21_en.json | 144 +- timing/day21_zh.json | 144 +- timing/day22_en.json | 144 +- timing/day22_zh.json | 144 +- timing/day23_en.json | 168 +- timing/day23_zh.json | 168 +- timing/day24_en.json | 132 +- timing/day24_zh.json | 132 +- timing/day25_en.json | 132 +- timing/day25_zh.json | 132 +- timing/day26_en.json | 156 +- timing/day26_zh.json | 156 +- timing/day27_en.json | 156 +- timing/day27_zh.json | 156 +- timing/day28_en.json | 156 +- timing/day28_zh.json | 156 +- timing/day29_en.json | 168 +- timing/day29_zh.json | 168 +- timing/day2_en.json | 192 +- timing/day2_zh.json | 192 +- timing/day30_en.json | 168 +- timing/day30_zh.json | 168 +- timing/day31_en.json | 132 +- timing/day31_zh.json | 132 +- timing/day32_en.json | 156 +- timing/day32_zh.json | 156 +- timing/day33_en.json | 144 +- timing/day33_zh.json | 144 +- timing/day34_en.json | 156 +- timing/day34_zh.json | 156 +- timing/day35_en.json | 156 +- timing/day35_zh.json | 156 +- timing/day36_en.json | 156 +- timing/day36_zh.json | 156 +- timing/day37_en.json | 132 +- timing/day37_zh.json | 132 +- timing/day38_en.json | 132 +- timing/day38_zh.json | 132 +- timing/day39_en.json | 132 +- timing/day39_zh.json | 132 +- timing/day3_en.json | 144 +- timing/day3_zh.json | 144 +- timing/day40_en.json | 132 +- timing/day40_zh.json | 132 +- timing/day4_en.json | 156 +- timing/day4_zh.json | 156 +- timing/day5_en.json | 180 +- timing/day5_zh.json | 180 +- timing/day6_en.json | 144 +- timing/day6_zh.json | 144 +- timing/day7_en.json | 156 +- timing/day7_zh.json | 156 +- timing/day8_en.json | 132 +- timing/day8_zh.json | 132 +- timing/day9_en.json | 144 +- timing/day9_zh.json | 144 +- .../advanced_environmental_protection_en.json | 76 +- .../advanced_environmental_protection_zh.json | 76 +- timing/reading/beginner_daily_routine_en.json | 100 +- timing/reading/beginner_daily_routine_zh.json | 100 +- .../beginner_self_introduction_en.json | 76 +- .../beginner_self_introduction_zh.json | 76 +- .../intermediate_at_the_restaurant_en.json | 88 +- .../intermediate_at_the_restaurant_zh.json | 88 +- .../intermediate_weekend_plans_en.json | 88 +- .../intermediate_weekend_plans_zh.json | 88 +- timing/supplementary/comparisons_en.json | 108 +- timing/supplementary/comparisons_zh.json | 108 +- timing/supplementary/daily_life_en.json | 216 +-- timing/supplementary/daily_life_zh.json | 216 +-- timing/supplementary/education_en.json | 156 +- timing/supplementary/education_zh.json | 156 +- timing/supplementary/emotions_en.json | 192 +- timing/supplementary/emotions_zh.json | 192 +- timing/supplementary/hobbies_en.json | 192 +- timing/supplementary/hobbies_zh.json | 192 +- .../writing/character_basic_strokes_en.json | 40 +- .../writing/character_basic_strokes_zh.json | 40 +- .../writing/character_common_radicals_en.json | 40 +- .../writing/character_common_radicals_zh.json | 40 +- ...racter_complete_radicals_-_group_1_en.json | 40 +- ...racter_complete_radicals_-_group_1_zh.json | 40 +- ...racter_complete_radicals_-_group_2_en.json | 40 +- ...racter_complete_radicals_-_group_2_zh.json | 40 +- ...racter_complete_radicals_-_group_3_en.json | 40 +- ...racter_complete_radicals_-_group_3_zh.json | 40 +- .../character_hsk1_-_essential_en.json | 40 +- .../character_hsk1_-_essential_zh.json | 40 +- timing/writing/character_hsk2_-_basic_en.json | 40 +- timing/writing/character_hsk2_-_basic_zh.json | 40 +- .../character_hsk3_-_intermediate_en.json | 40 +- .../character_hsk3_-_intermediate_zh.json | 40 +- timing/writing/character_numbers_en.json | 40 +- timing/writing/character_numbers_zh.json | 40 +- .../writing/character_theme_-_family_en.json | 40 +- .../writing/character_theme_-_family_zh.json | 40 +- timing/writing/character_theme_-_food_en.json | 40 +- timing/writing/character_theme_-_food_zh.json | 40 +- .../writing/character_theme_-_travel_en.json | 40 +- .../writing/character_theme_-_travel_zh.json | 40 +- timing/writing/sentence_advanced_en.json | 40 +- timing/writing/sentence_advanced_zh.json | 40 +- timing/writing/sentence_beginner_en.json | 40 +- timing/writing/sentence_beginner_zh.json | 40 +- timing/writing/sentence_intermediate_en.json | 40 +- timing/writing/sentence_intermediate_zh.json | 40 +- timing/writing/translation_advanced_en.json | 40 +- timing/writing/translation_advanced_zh.json | 40 +- timing/writing/translation_beginner_en.json | 40 +- timing/writing/translation_beginner_zh.json | 40 +- .../writing/translation_intermediate_en.json | 40 +- .../writing/translation_intermediate_zh.json | 40 +- writing.html | 335 ++-- 186 files changed, 15892 insertions(+), 12602 deletions(-) create mode 100644 ROADMAP.md create mode 100644 industry-standard.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 3424097..8fc622f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -10,6 +10,7 @@ A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: + 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' @@ -22,9 +23,10 @@ A clear and concise description of what you expected to happen. If applicable, add screenshots to help explain your problem. **Environment (please complete the following information):** - - OS: [e.g. macOS, Windows] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] + +- OS: [e.g. macOS, Windows] +- Browser [e.g. chrome, safari] +- Version [e.g. 22] **Additional context** Add any other context about the problem here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index f520177..82a4fbd 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -12,6 +12,7 @@ about: Submit a pull request **Related issues:** **Checklist:** + - [ ] Tests added/updated - [ ] Documentation updated - [ ] Linting and formatting pass diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6a7695c..53f34f5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,6 @@ version: 2 updates: - - package-ecosystem: "pip" - directory: "/" - schedule: - interval: "weekly" + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" diff --git a/CHANGELOG.md b/CHANGELOG.md index cdb1fc2..8e7ca6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] ### 2026-05-20 + - **Review & tooling:** Added review UI, starred phrases (`js/starred-phrases.js`), related stylesheet hooks, Playwright smoke tests (`tests/e2e`, `playwright.config.ts`), `package.json` / lockfile tooling, service worker registration path updates, `.gitignore` tweaks, `README`/workspace doc updates, and miscellaneous Flutter manifests/config touch-ups. - **Remove embedded video:** Stripped embedded YouTube usage from the PWA (video loaders, JSON, CSS/JS/HTML sections) and from the Flutter app (webview/youtube_player dependencies, `Lesson.videoId`, plugin registrations). README/Flutter README now describe bundled audio/transcripts instead of embedded streaming; removed obsolete internal strategy/instruction markdown files bundled with that cleanup. - **Hero:** Restored earlier hero layout and background image styling. @@ -15,4 +16,5 @@ All notable changes to this project will be documented in this file. - Updated requirements.txt (see details in commit) ## [1.0.0] - 2025-07-25 + - Initial public release. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 0b08678..94d9f44 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,16 +1,20 @@ # Contributor Covenant Code of Conduct ## Our Pledge + We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards + Examples of behavior that contributes to a positive environment: + - Using welcoming and inclusive language - Being respectful of differing viewpoints - Gracefully accepting constructive criticism - Showing empathy towards others ## Enforcement + Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at dbsectrainer@gmail.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 579435b..60483e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,6 +3,7 @@ Thank you for your interest in contributing! Please read the following guidelines to help us maintain a healthy and productive project. ## How to Contribute + - Fork the repository and create your branch from `main`. - Write clear, concise commit messages. - Ensure your code follows our style guidelines. @@ -10,14 +11,17 @@ Thank you for your interest in contributing! Please read the following guideline - Submit a pull request with a clear description of your changes. ## Coding Standards + - Use clear variable and function names. - Write docstrings/comments where helpful. - Follow PEP8 for Python code. ## Pull Request Process + - Reference related issues in your PR description. - Ensure all tests pass before requesting review. - Be responsive to feedback and requested changes. ## Code of Conduct + By participating, you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md). diff --git a/README.md b/README.md index b4cce5d..9986a6b 100644 --- a/README.md +++ b/README.md @@ -27,16 +27,17 @@ A focused Mandarin Chinese learning platform designed to take learners from foun ### Two Versions Available 1. **Flutter Mobile/Desktop App** (recommended) - Native performance, cross-platform - - Located in: `flutter_app/` - - See: [Flutter App Documentation](flutter_app/README.md) + - Located in: `flutter_app/` + - See: [Flutter App Documentation](flutter_app/README.md) 2. **Progressive Web App (PWA)** - Original web-based version - - Located in: Root directory - - Access via local server: `python server.py` + - Located in: Root directory + - Access via local server: `python server.py` ## Technical Skills Demonstrated ### Web Development + - Interactive, responsive web interface using HTML5, CSS3, and modern JavaScript - Dynamic content updates and micro-interactions for enhanced user engagement - Progressive Web App with offline-ready static assets (first-party lesson audio and transcripts) @@ -45,11 +46,13 @@ A focused Mandarin Chinese learning platform designed to take learners from foun - Interactive reading comprehension exercises with vocabulary tools ### Python Development + - Automated content generation scripts for lesson materials - Efficient audio file processing and generation - Asynchronous programming for optimized performance ### Educational Technology + - Structured 40-day curriculum design with progressive learning paths - Interactive learning tools and progress tracking systems - Multimedia content integration (text, audio, interactive exercises) @@ -57,92 +60,101 @@ A focused Mandarin Chinese learning platform designed to take learners from foun - Reading comprehension exercises with vocabulary support ### Multilingual Support + - Trilingual content management (Simplified Chinese, Pinyin, English) - Dynamic language switching functionality - Cultural context integration ### User Experience Design + - Progress tracking with completion badges - Offline-capable web application - Persistent user preferences and progress storage - Intuitive navigation and learning flow ### Audio Processing + - Dual-language audio content management - Native speaker pronunciation integration - Custom audio playback controls ## Project Structure + - `index.html`: Main dashboard with progress tracking - `day.html`: Daily lesson interface with audio and text content - `supplementary.html`: Additional learning resources and practice materials - `reading.html`: Interactive reading practice with comprehension exercises - `writing.html`: Character writing practice with canvas-based drawing - `css/`: Stylesheets for the web interface - - `styles.css`: Main stylesheet - - `reading.css`: Styles for reading practice interface - - `writing.css`: Styles for writing practice interface + - `styles.css`: Main stylesheet + - `reading.css`: Styles for reading practice interface + - `writing.css`: Styles for writing practice interface - `js/`: JavaScript functionality and interactive features - - `script.js`: Core application functionality - - `character-drawing.js`: Canvas-based drawing system for character practice -- `audio_files/`: - - Daily lesson audio files in both English (`day{n}_en.mp3`) and Mandarin (`day{n}_zh.mp3`) - - Supplementary audio content in the `supplementary/` subdirectory - - Reading practice audio in the `reading/` subdirectory - - Writing practice audio in the `writing/` subdirectory + - `script.js`: Core application functionality + - `character-drawing.js`: Canvas-based drawing system for character practice +- `audio_files/`: + - Daily lesson audio files in both English (`day{n}_en.mp3`) and Mandarin (`day{n}_zh.mp3`) + - Supplementary audio content in the `supplementary/` subdirectory + - Reading practice audio in the `reading/` subdirectory + - Writing practice audio in the `writing/` subdirectory - `text_files/`: Text transcripts for each lesson: - - Simplified Chinese (`day{n}_zh.txt`) - - Pinyin (`day{n}_pinyin.txt`) - - English (`day{n}_en.txt`) + - Simplified Chinese (`day{n}_zh.txt`) + - Pinyin (`day{n}_pinyin.txt`) + - English (`day{n}_en.txt`) - `timing/`: Karaoke-style cue files for synced playback with transcripts: - - Daily lessons: `timing/day{n}_zh.json`, `timing/day{n}_en.json` - - Supplementary categories: `timing/supplementary/{category}_zh.json` (and `_en`) - - Reading passages: `timing/reading/{level}_{topic_slug}_zh.json` (and `_en`) - - Writing activity intros (title + description): `timing/writing/{type}_{level_slug}_zh.json` (and `_en`) - - **Chinese vs Latin UI:** Mandarin timings are generated against Chinese sentences. Reading with `lang=pinyin` loads **`_zh` audio + timings** alongside the romanized transcript (segmented like English), so cues track the same passages as `_zh`; per-token granularity is weaker than hanzi spans. Supplementary behaves the same (`pinyin` text + `_zh.json` cues). Writing with `lang=pinyin` plays the **`_zh`** intro clip (aligned to **`description_zh`**); the romanized heading uses **phrase-level** highlighting on those two cues (no `.lesson-token` children) because syllable timing differs from Mandarin speech. - - **Writing `lang=zh`:** Karaoke and **`_zh`** TTS match the Chinese title plus **`description_zh`** on each block in **`writing_activities.py`**. Mandarin stitching lives in **`scripts/audio_timings.py`**; **`generate_writing_file`** writes **`description_zh`** into zh text files only. **`_en`** intros use English copy. - - Manifests regenerate with their MP3s when you run the Python generators. + - Daily lessons: `timing/day{n}_zh.json`, `timing/day{n}_en.json` + - Supplementary categories: `timing/supplementary/{category}_zh.json` (and `_en`) + - Reading passages: `timing/reading/{level}_{topic_slug}_zh.json` (and `_en`) + - Writing activity intros (title + description): `timing/writing/{type}_{level_slug}_zh.json` (and `_en`) + - **Chinese vs Latin UI:** Mandarin timings are generated against Chinese sentences. Reading with `lang=pinyin` loads **`_zh` audio + timings** alongside the romanized transcript (segmented like English), so cues track the same passages as `_zh`; per-token granularity is weaker than hanzi spans. Supplementary behaves the same (`pinyin` text + `_zh.json` cues). Writing with `lang=pinyin` plays the **`_zh`** intro clip (aligned to **`description_zh`**); the romanized heading uses **phrase-level** highlighting on those two cues (no `.lesson-token` children) because syllable timing differs from Mandarin speech. + - **Writing `lang=zh`:** Karaoke and **`_zh`** TTS match the Chinese title plus **`description_zh`** on each block in **`writing_activities.py`**. Mandarin stitching lives in **`scripts/audio_timings.py`**; **`generate_writing_file`** writes **`description_zh`** into zh text files only. **`_en`** intros use English copy. + - Manifests regenerate with their MP3s when you run the Python generators. - `reading_files/`: Text content for reading practice exercises - `writing_files/`: Character practice content and writing exercises - `manifest.json`: PWA configuration for installable app features - `sw.js`: Service Worker for offline functionality and caching - `icons/`: PWA icons for various device sizes and resolutions - - `icon-72x72.png` to `icon-512x512.png`: Progressive sizes for different devices - - `icon.svg`: Scalable vector icon + - `icon-72x72.png` to `icon-512x512.png`: Progressive sizes for different devices + - `icon.svg`: Scalable vector icon - Python content generation scripts: - - `mandarin_phrases_days_01_07.py`: Days 1-7 content - - `mandarin_phrases_days_08_14.py`: Days 8-14 content - - `mandarin_phrases_days_15_22.py`: Days 15-22 content - - `mandarin_phrases_days_23_30.py`: Days 23-30 content - - `mandarin_phrases_days_31_40.py`: Days 31-40 content - - `mandarin_phrases_supplementary.py`: Additional practice content - - `reading_activities.py`: Reading practice content generator - - `writing_activities.py`: Writing practice content generator - - `video_search.py`: Stub only (embedded YouTube was removed); kept so old references exit gracefully + - `mandarin_phrases_days_01_07.py`: Days 1-7 content + - `mandarin_phrases_days_08_14.py`: Days 8-14 content + - `mandarin_phrases_days_15_22.py`: Days 15-22 content + - `mandarin_phrases_days_23_30.py`: Days 23-30 content + - `mandarin_phrases_days_31_40.py`: Days 31-40 content + - `mandarin_phrases_supplementary.py`: Additional practice content + - `reading_activities.py`: Reading practice content generator + - `writing_activities.py`: Writing practice content generator + - `video_search.py`: Stub only (embedded YouTube was removed); kept so old references exit gracefully ## Course Structure (40 Days) ### Foundations (Days 1–7) + - Pinyin system, tones, and pronunciation - Greetings, numbers, time, and basic Q&A - Introduction to characters ### Essential Daily Phrases (Days 8–14) + - Shopping, transportation, dining, and directions - Basic sentence patterns and grammar - Survival Mandarin for travelers ### Cultural Context & Daily Life (Days 15–22) + - Family, social interactions, and etiquette - Chinese festivals and traditions - Everyday communication at home and in public ### Professional Communication (Days 23–30) + - Workplace vocabulary and business etiquette - Remote work and online meetings - Emails, presentations, and technical phrases ### Advanced Fluency & Real-World Use (Days 31–40) + - Idioms, slang, and formal expressions - Debates, storytelling, and persuasive speech - Practice dialogues and role-play @@ -150,6 +162,7 @@ A focused Mandarin Chinese learning platform designed to take learners from foun ## Features ### Progressive Web App (PWA) + - Install as a standalone app on desktop and mobile devices - Offline access to lessons, audio, and practice materials - Push notifications for daily lesson reminders @@ -160,17 +173,20 @@ A focused Mandarin Chinese learning platform designed to take learners from foun - Works across all modern browsers and devices ### Interactive Learning Interface + - Dual audio tracks (English explanation + native Mandarin pronunciation) - Interactive transcripts (Simplified, Pinyin, English) - Daily progress tracker with lesson completion badges - Mobile-friendly, offline-capable web interface ### Mandarin-Specific Tools + - Tone practice and audio drills - Pinyin-to-Hanzi recognition games - Cultural tips embedded in lessons ### Reading Practice + - Leveled reading materials (Beginner, Intermediate, Advanced) - Interactive vocabulary lists with pronunciation - Comprehension questions with self-assessment @@ -178,50 +194,56 @@ A focused Mandarin Chinese learning platform designed to take learners from foun - Multi-language support (Simplified Chinese, Pinyin, English) ### Character Writing Practice + - Canvas-based drawing system for authentic character practice - Works with both mouse and touch screen devices - Grid system for proper character proportions - Stroke order guidance and hints - Multiple difficulty levels: - - Basic strokes and radicals - - HSK-leveled characters (HSK 1-6) - - Thematic character groups (Family, Food, Travel, etc.) + - Basic strokes and radicals + - HSK-leveled characters (HSK 1-6) + - Thematic character groups (Family, Food, Travel, etc.) - Clear, Undo, and Hint functionality for learning support ## Why Mandarin? ### 1. Global Importance + - Spoken by over 1 billion people - Key to accessing China's economic, cultural, and technological landscape ### 2. Career & Business Edge + - High demand in diplomacy, tech, import/export, and finance - Opens doors in international business, academia, and NGOs ### 3. Cultural & Intellectual Access + - Dive into Chinese philosophy, literature, and modern media - Enhanced understanding of cross-cultural dynamics ## Development Setup ### Requirements + - Python 3.12+ -- **ffmpeg** on your PATH (required by ``pydub`` when stitching per-phrase TTS into `audio_files/day{n}_*.mp3` and writing `timing/day{n}_*.json`) +- **ffmpeg** on your PATH (required by `pydub` when stitching per-phrase TTS into `audio_files/day{n}_*.mp3` and writing `timing/day{n}_*.json`) - Required Python packages: - ```bash - pip install gtts edge-tts pandas pydub - ``` - Or install from the pinned list: + ```bash + pip install gtts edge-tts pandas pydub + ``` - ```bash - pip install -r requirements.txt - ``` + Or install from the pinned list: - Regenerating Mandarin lesson audio builds **one stitched MP3 per day/voice plus** cue files under `timing/` so the web day lesson can sync highlights with playback. + ```bash + pip install -r requirements.txt + ``` + Regenerating Mandarin lesson audio builds **one stitched MP3 per day/voice plus** cue files under `timing/` so the web day lesson can sync highlights with playback. ### Generate Lessons + ```bash # Generate content for each section python mandarin_phrases_days_01_07.py @@ -235,14 +257,18 @@ python mandarin_phrases_supplementary.py ``` ### Run the Site + For basic usage and PWA features: + ```bash # Using Python's built-in server python -m http.server 8000 ``` + Then open `http://localhost:8000` in your browser. Note: Running through a local server is required to enable PWA features: + 1. Use Chrome or another modern browser that supports PWAs 2. Look for the install prompt in the address bar to install as a standalone app 3. Test offline functionality by disabling network in DevTools @@ -250,7 +276,9 @@ Note: Running through a local server is required to enable PWA features: 5. Clear site data in browser settings to test the service worker update process ## Project Standards & Best Practices + This project follows industry best practices for open source repositories: + - [x] MIT License ([LICENSE](LICENSE)) - [x] Contribution guidelines ([CONTRIBUTING.md](CONTRIBUTING.md)) - [x] Code of Conduct ([CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)) @@ -261,6 +289,7 @@ This project follows industry best practices for open source repositories: - [x] Funding options ([.github/FUNDING.yml](.github/FUNDING.yml)) ## Usage Guide + 1. Open `index.html` and select your lesson 2. Listen to both English explanation and native Mandarin pronunciation 3. Read along with Pinyin and Hanzi @@ -270,7 +299,9 @@ This project follows industry best practices for open source repositories: 7. Complete your daily badge and track your fluency gains ## Storage + Uses localStorage to save: + - Completed lessons - Last visited day - Audio playback preferences (e.g. slow speed, loop) diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..86fa76d --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,104 @@ +# Gap-Closing Roadmap — Mandarin Pathways → toward the "industry standard" + +## Context + +`industry-standard.md` describes an aspirational super-app ("Mandarin Mastery") with AI conversation, speech +recognition, OCR, adaptive learning, SRS, gamification, and a subscription business — backed by a Node/Python + +Postgres + AWS stack. The **actual** project (Mandarin Pathways) is a strong but narrower product: an offline-first, +trilingual (zh / pinyin / en) 40-day course delivered as a vanilla-JS **PWA** and a **Flutter** app, with **no +backend, no database, no auth**. Of the ~18 flagship features in the standard, ~3 are fully present, ~5 partial, ~10 +absent. + +This roadmap closes the **highest-leverage gaps that do NOT require a backend**, so they can ship within the current +offline architecture. Per the user's decision, work targets the **Web PWA first**; Flutter parity is a later phase. +Out of scope (require server/LLM infra, tracked separately): AI conversation partner, cloud sync/accounts, +leaderboards, language exchange, subscriptions. Speech-recognition pronunciation feedback is a *stretch* item (browser +Web Speech API is client-side but network-dependent and inconsistent). + +**Intended outcome:** move from "static courseware" toward "engaging, retention-driven self-study app" by adding the +retention loop (SRS, streaks), self-assessment (placement + quizzes), and two learning-depth features (tone visuals, +stroke-order animation) — all offline, all in the PWA. + +## Guiding constraints + +- **No backend.** Everything persists in `localStorage` (web) following existing patterns. +- **Reuse existing patterns**, don't reinvent: + - localStorage feature module pattern: `js/starred-phrases.js` (get/save/toggle + stable composite ID). + - Import/export of progress keys: `js/data-portability.js` (must be updated to include any new keys). + - Canvas practice: `js/character-drawing.js` (note line ~423: "We don't have stroke data yet" — the hook for animation). + - Page bootstrapping pattern: `js/day-page.js`, `js/reading-page.js`, `js/writing-page.js`. + - Service worker cache list: `sw.js` (bump cache version + add any new JS/CSS/page files). +- **Add tests as you go** — the repo has only ~6 Playwright smoke tests (`tests/e2e/smoke.spec.ts`). Each new page/feature + gets at least one smoke test. (A lightweight `.github/workflows/test.yml` running `npm test` is a recommended enabler.) + +## Phase 1 — Retention loop (highest leverage) + +**1a. Spaced-Repetition flashcards (SRS).** Closes the "Anki-style SRS: ABSENT" gap. +- New `js/srs.js` modeled on `js/starred-phrases.js`: store per-card scheduling state under a new key `srsCards` + (`{ id, day, lang, front, back, due, interval, ease, reps }`). Use a small SM-2-lite algorithm (interval/ease update + on a 3-button "Again / Good / Easy" grade). +- Seed cards from existing data: starred phrases (`getStarredPhrases()`) + lesson phrases + reading vocabulary lists + (already structured in `reading_activities.py` output / `reading_files/`). +- New `srs.html` review page (clone structure of `review.html`); link from home (`index.html`) and the review page. +- Update `js/data-portability.js` to export/import the `srsCards` key; add `srs.html`/`js/srs.js` to `sw.js`. + +**1b. Streaks & achievements.** Closes "streaks/achievements: ABSENT." +- New `js/streaks.js`: track `lastActiveDate` and `streakCount` in localStorage; increment on any lesson/SRS activity, + reset on a missed day. Simple achievement badges derived from existing `completedDays` (e.g., 7/14/30/40-day, first + SRS review, etc.) — no new data source needed. +- Surface streak + badges on the home dashboard (`index.html` progress section) and as a small banner on `day.html`. +- Add keys to `js/data-portability.js`. + +## Phase 2 — Self-assessment + +**2a. Placement test.** Closes "placement test: ABSENT." +- New `placement.html` + `js/placement-page.js`: ~8–12 static multiple-choice questions drawn from existing phrase/vocab + content across difficulty bands; map score → recommended starting day/section. Store `placementResult` in localStorage; + offer a "Start at Day N" CTA on the home page. + +**2b. HSK self-quiz / mock test.** Closes "HSK mock tests: ABSENT" (partial). +- New `js/quiz.js` reusable quiz engine (multiple-choice + fill-in) sourced from existing day phrases and reading vocab; + embed a "Quiz me on this day" button on `day.html` and a standalone `quiz.html`. Store best scores per day. + +## Phase 3 — Learning depth + +**3a. Tone visualization.** Closes "tone training: ABSENT." +- New `js/tone-visualizer.js`: render the 4 Mandarin tone pitch-contour shapes (flat / rising / dip / falling) as small + inline SVG/canvas glyphs next to pinyin syllables on `day.html`. Pure static rendering keyed off the tone digit in + pinyin — no audio analysis, no backend. Add a "Tones 101" explainer card. + +**3b. Stroke-order animation.** Closes "stroke-order animations: ABSENT." +- Integrate **Hanzi Writer** (MIT, fully client-side, bundles its own stroke data — no backend) into the writing flow. + Replace/augment the static hint in `js/character-drawing.js` (the line ~423 TODO) with animated stroke-order playback + and quiz mode. Vendor the library locally and add to `sw.js` so it stays offline. + +## Phase 4 — Flutter parity (later) + +Once Phase 1–3 land and stabilize in the PWA, port each feature to the Flutter app using its existing +`StorageService` pattern (`flutter_app/lib/services/storage_service.dart` — JSON-encoded values in SharedPreferences, +mirroring the localStorage keys). Reuse the same key names so exported web data and Flutter data stay conceptually aligned. + +## Stretch (optional, evaluate later) + +- **Pronunciation feedback** via the browser Web Speech API (`SpeechRecognition`) comparing recognized text to the target + phrase. Client-side but network-dependent and Chrome-biased — prototype before committing. +- **Searchable offline dictionary**: aggregate all phrase + reading-vocab data into one indexed glossary page (upgrades + the "dictionary: PARTIAL" gap without external data). + +## Also recommended (separate, not features) + +- **Fix `industry-standard.md`**: its tech-stack section (React Native / Postgres / AWS) and metrics are fiction relative + to the repo. Re-baseline it (or fold into a real roadmap) so the "standard" is honest and achievable. +- **Add CI** (`.github/workflows/test.yml`) running `npm test` on PR — currently no GitHub Actions exist; this protects + every feature above. + +## Verification + +- **Per feature:** add a Playwright smoke test in `tests/e2e/` (page loads, core control works, localStorage key written), + following `tests/e2e/smoke.spec.ts`. Run `python3 server.py` then `npm test` (config: `playwright.config.ts`, + baseURL `http://127.0.0.1:8000`). +- **Manual:** `python3 server.py` → exercise each new page in-browser; confirm offline behavior by loading once, going + offline (DevTools), and reloading (service worker must serve the new files — verify `sw.js` cache version was bumped). +- **Data portability:** export progress via `js/data-portability.js`, confirm new keys (`srsCards`, streak keys, + `placementResult`, quiz scores) round-trip through import. +- **Regression:** existing smoke tests still pass; `CHANGELOG.md` updated per repo convention. diff --git a/SPONSORSHIP.md b/SPONSORSHIP.md index 1fd1990..5af9732 100644 --- a/SPONSORSHIP.md +++ b/SPONSORSHIP.md @@ -7,32 +7,32 @@ GitHub Sponsors is a funding platform that enables the open source community to ## How GitHub Sponsors Works 1. **For Sponsors (Supporters)** - - Can make monthly recurring payments to support developers - - Get special access to sponsor-only content, updates, or features - - Receive recognition through sponsor badges on GitHub - - Can choose different sponsorship tiers with varying benefits - - 100% of sponsorships go to developers (GitHub charges no fees) + - Can make monthly recurring payments to support developers + - Get special access to sponsor-only content, updates, or features + - Receive recognition through sponsor badges on GitHub + - Can choose different sponsorship tiers with varying benefits + - 100% of sponsorships go to developers (GitHub charges no fees) 2. **For Recipients (Developers/Organizations)** - - Receive monthly funding from sponsors - - Can offer different sponsorship tiers with unique benefits - - Get matched funding from GitHub (up to $5,000 in first year) - - Can engage with sponsors through exclusive updates and content + - Receive monthly funding from sponsors + - Can offer different sponsorship tiers with unique benefits + - Get matched funding from GitHub (up to $5,000 in first year) + - Can engage with sponsors through exclusive updates and content ## Setting Up GitHub Sponsors 1. **Eligibility Requirements** - - Have a GitHub account - - Have 2FA enabled on your GitHub account - - Have a bank account in a supported region - - Follow GitHub Community Guidelines - - Have a complete GitHub profile + - Have a GitHub account + - Have 2FA enabled on your GitHub account + - Have a bank account in a supported region + - Follow GitHub Community Guidelines + - Have a complete GitHub profile 2. **Application Process** - - Go to [GitHub Sponsors](https://github.com/sponsors) - - Click "Join the waitlist" or "Set up GitHub Sponsors" - - Complete the application form - - Wait for approval (typically 1-2 weeks) + - Go to [GitHub Sponsors](https://github.com/sponsors) + - Click "Join the waitlist" or "Set up GitHub Sponsors" + - Complete the application form + - Wait for approval (typically 1-2 weeks) ## Mandarin Pathways Sponsorship Profile @@ -63,29 +63,34 @@ Join us in making Mandarin learning accessible to everyone, everywhere! ## Sponsorship Tiers ### 1. 学生 Student Supporter ($5/month) + - ✨ Sponsor badge on GitHub - 🎯 Name in supporters.md - 📝 Access to sponsor-only updates ### 2. 朋友 Friend ($10/month) + - All Student benefits, plus: - 🎵 Early access to new audio content - 📚 Vote on new lesson topics - 💬 Priority issue responses ### 3. 老师 Teacher ($25/month) + - All Friend benefits, plus: - 🎥 Behind-the-scenes development updates - 🔍 Monthly progress reports - 💡 Suggest new features ### 4. 大师 Master ($50/month) + - All Teacher benefits, plus: - 👥 1-on-1 monthly consultation - 🌟 Featured sponsor status - 🎨 Input on design decisions ### 5. 企业 Enterprise ($100/month) + - All Master benefits, plus: - 💼 Custom deployment support - 🏢 Organization logo on README @@ -94,23 +99,23 @@ Join us in making Mandarin learning accessible to everyone, everywhere! ## Funding Goals 1. **$200/month** - - Regular content updates - - Basic maintenance + - Regular content updates + - Basic maintenance 2. **$500/month** - - New audio recordings - - Enhanced interactive features - - Weekly updates + - New audio recordings + - Enhanced interactive features + - Weekly updates 3. **$1000/month** - - Professional voice actors - - Mobile app development - - Advanced learning tools + - Professional voice actors + - Mobile app development + - Advanced learning tools 4. **$2000/month** - - Full-time development - - AI-powered features - - Expanded curriculum + - Full-time development + - AI-powered features + - Expanded curriculum ## How Funds Are Used @@ -123,19 +128,19 @@ Join us in making Mandarin learning accessible to everyone, everywhere! ## Engagement Strategy 1. **Regular Updates** - - Weekly development logs - - Monthly progress reports - - Quarterly roadmap updates + - Weekly development logs + - Monthly progress reports + - Quarterly roadmap updates 2. **Community Involvement** - - Sponsor-only polls - - Feature voting - - Development discussions + - Sponsor-only polls + - Feature voting + - Development discussions 3. **Recognition** - - Sponsor wall in README - - Social media shoutouts - - Contribution acknowledgments + - Sponsor wall in README + - Social media shoutouts + - Contribution acknowledgments ## Getting Started @@ -148,23 +153,23 @@ Join us in making Mandarin learning accessible to everyone, everywhere! ## Best Practices 1. **Communication** - - Respond promptly to sponsor messages - - Post regular updates - - Be transparent about development + - Respond promptly to sponsor messages + - Post regular updates + - Be transparent about development 2. **Content Management** - - Keep sponsor-only content updated - - Document sponsor benefits clearly - - Maintain an organized repository + - Keep sponsor-only content updated + - Document sponsor benefits clearly + - Maintain an organized repository 3. **Community Building** - - Engage with sponsors regularly - - Host sponsor-only events - - Recognize contributor milestones + - Engage with sponsors regularly + - Host sponsor-only events + - Recognize contributor milestones 4. **Project Management** - - Track sponsor-suggested features - - Maintain a public roadmap - - Document progress regularly + - Track sponsor-suggested features + - Maintain a public roadmap + - Document progress regularly Remember to keep your sponsorship tiers, goals, and benefits updated as your project grows and evolves. Regular engagement with sponsors and transparent communication about how funds are used will help build a sustainable sponsorship program. diff --git a/css/reading.css b/css/reading.css index 37ac306..39e1635 100644 --- a/css/reading.css +++ b/css/reading.css @@ -190,9 +190,9 @@ .vocabulary-list { grid-template-columns: 1fr; } - + .level-buttons, .topic-buttons { flex-direction: column; } -} \ No newline at end of file +} diff --git a/css/styles.css b/css/styles.css index 1aba686..23fdfc7 100644 --- a/css/styles.css +++ b/css/styles.css @@ -1,11 +1,14 @@ /* Preload fonts to prevent layout shifts */ @font-face { - font-family: 'Noto Sans SC'; + font-family: "Noto Sans SC"; font-style: normal; font-weight: 400; font-display: swap; - src: local('Noto Sans SC Regular'), local('NotoSansSC-Regular'), - url('https://fonts.gstatic.com/s/notosanssc/v12/k3kXo84MPvpLmixcA63oeALhLOCT-xWNm8Hqd37g1OkDRZe7lR4sg1IzSy-MNbE9VH8V.119.woff2') format('woff2'); + src: + local("Noto Sans SC Regular"), + local("NotoSansSC-Regular"), + url("https://fonts.gstatic.com/s/notosanssc/v12/k3kXo84MPvpLmixcA63oeALhLOCT-xWNm8Hqd37g1OkDRZe7lR4sg1IzSy-MNbE9VH8V.119.woff2") + format("woff2"); unicode-range: U+4E00-9FFF; } @@ -21,7 +24,7 @@ } body { - font-family: 'Poppins', 'Noto Sans SC', Arial, sans-serif; + font-family: "Poppins", "Noto Sans SC", Arial, sans-serif; margin: 0; padding: 0; background-color: var(--secondary-color); @@ -35,7 +38,7 @@ header { color: white; padding: 2em 0; text-align: center; - box-shadow: 0 4px 6px rgba(0,0,0,0.1); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); } header h1 { @@ -52,7 +55,8 @@ header p { } .hero-section { - background: url('https://images.unsplash.com/photo-1546410531-89436e5b419a?auto=format&fit=crop&w=1920&q=80') center/cover; + background: url("https://images.unsplash.com/photo-1546410531-89436e5b419a?auto=format&fit=crop&w=1920&q=80") + center/cover; padding: 100px 20px; text-align: center; color: white; @@ -60,13 +64,18 @@ header p { } .hero-section::before { - content: ''; + content: ""; position: absolute; top: 0; left: 0; right: 0; bottom: 0; - background: rgba(44, 62, 80, 0.7); /* Reduced opacity for better visibility */ + background: rgba( + 44, + 62, + 80, + 0.7 + ); /* Reduced opacity for better visibility */ } .hero-content { @@ -97,7 +106,11 @@ main { position: absolute; width: 200px; height: 200px; - background-image: radial-gradient(circle, var(--accent-color) 1px, transparent 1px); + background-image: radial-gradient( + circle, + var(--accent-color) 1px, + transparent 1px + ); background-size: 20px 20px; opacity: 0.1; z-index: -1; @@ -117,15 +130,17 @@ main { background: white; border-radius: 15px; overflow: hidden; - box-shadow: 0 10px 20px rgba(0,0,0,0.1); - transition: transform 0.3s ease, box-shadow 0.3s ease; + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1); + transition: + transform 0.3s ease, + box-shadow 0.3s ease; cursor: pointer; position: relative; } .language-card:hover { transform: translateY(-10px); - box-shadow: 0 15px 30px rgba(0,0,0,0.15); + box-shadow: 0 15px 30px rgba(0, 0, 0, 0.15); } .language-card::after { @@ -140,7 +155,9 @@ main { border-radius: 6px; font-size: 0.85em; opacity: 0; - transition: opacity 0.3s ease, bottom 0.3s ease; + transition: + opacity 0.3s ease, + bottom 0.3s ease; pointer-events: none; white-space: nowrap; z-index: 10; @@ -153,7 +170,11 @@ main { .card-header { padding: 20px; - background: linear-gradient(135deg, var(--accent-color), var(--hover-color)); + background: linear-gradient( + 135deg, + var(--accent-color), + var(--hover-color) + ); color: white; } @@ -219,7 +240,12 @@ main { /* Section dividers */ .section-divider { height: 3px; - background: linear-gradient(90deg, transparent, var(--accent-color), transparent); + background: linear-gradient( + 90deg, + transparent, + var(--accent-color), + transparent + ); margin: 20px auto 40px; width: 80%; max-width: 800px; @@ -228,7 +254,7 @@ main { } .section-divider::before { - content: ''; + content: ""; position: absolute; width: 30px; height: 30px; @@ -265,14 +291,16 @@ main { background: white; padding: 25px; border-radius: 12px; - box-shadow: 0 4px 6px rgba(0,0,0,0.1); - transition: transform 0.3s ease, box-shadow 0.3s ease; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + transition: + transform 0.3s ease, + box-shadow 0.3s ease; border-left: 4px solid var(--accent-color); } .section-card:hover { transform: translateY(-5px); - box-shadow: 0 8px 15px rgba(0,0,0,0.15); + box-shadow: 0 8px 15px rgba(0, 0, 0, 0.15); } .section-card h3 { @@ -344,7 +372,7 @@ main { border-radius: 20px; font-weight: 600; color: var(--primary-color); - box-shadow: 0 2px 4px rgba(0,0,0,0.1); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .day-grid { @@ -364,14 +392,14 @@ main { color: var(--text-color); text-decoration: none; border-radius: 12px; - box-shadow: 0 4px 6px rgba(0,0,0,0.1); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); transition: all 0.3s ease; position: relative; } .day-grid a:hover { transform: translateY(-5px); - box-shadow: 0 8px 15px rgba(0,0,0,0.15); + box-shadow: 0 8px 15px rgba(0, 0, 0, 0.15); background: var(--accent-color); color: white; } @@ -416,7 +444,7 @@ main { justify-content: center; align-items: center; cursor: pointer; - box-shadow: 0 4px 10px rgba(0,0,0,0.2); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2); opacity: 0; visibility: hidden; transition: all 0.3s ease; @@ -517,8 +545,10 @@ footer { background: white; padding: 30px; border-radius: 15px; - box-shadow: 0 6px 12px rgba(0,0,0,0.15); - transition: transform 0.3s ease, box-shadow 0.3s ease; + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15); + transition: + transform 0.3s ease, + box-shadow 0.3s ease; text-decoration: none; color: var(--text-color); position: relative; @@ -530,7 +560,7 @@ footer { .core-skill-card:hover { transform: translateY(-10px); - box-shadow: 0 12px 24px rgba(0,0,0,0.2); + box-shadow: 0 12px 24px rgba(0, 0, 0, 0.2); } .core-skill-card .card-icon { @@ -566,11 +596,15 @@ footer { } .core-skill-card::before { - content: ''; + content: ""; position: absolute; width: 250px; height: 250px; - background-image: radial-gradient(circle, var(--accent-color) 1px, transparent 1px); + background-image: radial-gradient( + circle, + var(--accent-color) 1px, + transparent 1px + ); background-size: 20px 20px; opacity: 0.1; z-index: 0; @@ -583,17 +617,17 @@ footer { .core-skills-grid { grid-template-columns: 1fr; } - + .core-skill-card { padding: 25px; } - + .core-skill-card .card-icon { width: 60px; height: 60px; font-size: 1.5em; } - + .core-skill-card h3 { font-size: 1.3em; } @@ -624,7 +658,7 @@ footer { background: white; padding: 25px; border-radius: 12px; - box-shadow: 0 4px 6px rgba(0,0,0,0.1); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); text-decoration: none; color: var(--text-color); position: relative; @@ -642,7 +676,7 @@ footer { /* Chinese text styling */ .zh { - font-family: 'Noto Sans SC', sans-serif; + font-family: "Noto Sans SC", sans-serif; } /* Language selector styles */ @@ -671,7 +705,7 @@ footer { .language-btn.active { background: rgba(255, 255, 255, 0.3); transform: translateY(-2px); - box-shadow: 0 2px 4px rgba(0,0,0,0.2); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); font-weight: 600; } @@ -681,7 +715,7 @@ footer { flex-wrap: wrap; gap: 5px; } - + .language-btn { padding: 6px 10px; font-size: 0.85em; @@ -689,11 +723,15 @@ footer { } .supplementary-card::before { - content: ''; + content: ""; position: absolute; width: 200px; height: 200px; - background-image: radial-gradient(circle, var(--accent-color) 1px, transparent 1px); + background-image: radial-gradient( + circle, + var(--accent-color) 1px, + transparent 1px + ); background-size: 20px 20px; opacity: 0.1; z-index: 0; @@ -704,7 +742,7 @@ footer { .supplementary-card:hover { transform: translateY(-10px); - box-shadow: 0 8px 15px rgba(0,0,0,0.15); + box-shadow: 0 8px 15px rgba(0, 0, 0, 0.15); } .card-icon { @@ -754,14 +792,19 @@ footer { left: 50%; height: 100%; width: 4px; - background: linear-gradient(to bottom, transparent, var(--accent-color), transparent); + background: linear-gradient( + to bottom, + transparent, + var(--accent-color), + transparent + ); opacity: 0.2; z-index: 0; } .journey-path::before, .journey-path::after { - content: ''; + content: ""; position: absolute; width: 20px; height: 20px; @@ -798,13 +841,13 @@ footer { background: white; padding: 25px; border-radius: 12px; - box-shadow: 0 4px 6px rgba(0,0,0,0.1); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); transition: transform 0.3s ease; } .benefit-card:hover { transform: translateY(-5px); - box-shadow: 0 8px 15px rgba(0,0,0,0.15); + box-shadow: 0 8px 15px rgba(0, 0, 0, 0.15); } .benefit-card h3 { @@ -869,13 +912,13 @@ footer { .benefits-grid { grid-template-columns: 1fr; } - + .combo-row { flex-direction: column; align-items: flex-start; gap: 5px; } - + .benefit { text-align: left; } @@ -905,30 +948,60 @@ footer { /* Animations */ @keyframes fadeIn { - from { opacity: 0; transform: translateY(20px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } } @keyframes pulse { - 0% { transform: scale(1); } - 50% { transform: scale(1.05); } - 100% { transform: scale(1); } + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.05); + } + 100% { + transform: scale(1); + } } @keyframes float { - 0% { transform: translateY(0px); } - 50% { transform: translateY(-10px); } - 100% { transform: translateY(0px); } + 0% { + transform: translateY(0px); + } + 50% { + transform: translateY(-10px); + } + 100% { + transform: translateY(0px); + } } @keyframes slideInRight { - from { opacity: 0; transform: translateX(50px); } - to { opacity: 1; transform: translateX(0); } + from { + opacity: 0; + transform: translateX(50px); + } + to { + opacity: 1; + transform: translateX(0); + } } @keyframes slideInLeft { - from { opacity: 0; transform: translateX(-50px); } - to { opacity: 1; transform: translateX(0); } + from { + opacity: 0; + transform: translateX(-50px); + } + to { + opacity: 1; + transform: translateX(0); + } } .animate-fade-in { @@ -966,7 +1039,7 @@ footer { background: white; border-radius: 15px; padding: 30px; - box-shadow: 0 4px 6px rgba(0,0,0,0.1); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); margin: 20px 0; position: relative; animation: fadeIn 0.8s ease forwards; @@ -1061,11 +1134,15 @@ footer { } .phrase-section::before { - content: ''; + content: ""; position: absolute; width: 200px; height: 200px; - background-image: radial-gradient(circle, var(--accent-color) 1px, transparent 1px); + background-image: radial-gradient( + circle, + var(--accent-color) 1px, + transparent 1px + ); background-size: 20px 20px; opacity: 0.1; z-index: 0; @@ -1101,7 +1178,7 @@ footer { align-items: center; gap: 8px; transition: all 0.3s ease; - box-shadow: 0 2px 4px rgba(0,0,0,0.05); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); } .phrase-reading { @@ -1111,12 +1188,16 @@ footer { } .phrase-item.audio-sync-active { - box-shadow: 0 0 0 2px rgba(26, 92, 143, 0.35), 0 4px 8px rgba(0,0,0,0.1); + box-shadow: + 0 0 0 2px rgba(26, 92, 143, 0.35), + 0 4px 8px rgba(0, 0, 0, 0.1); background-color: #f9fcff; } .lesson-token { - transition: background-color 0.12s ease, color 0.12s ease; + transition: + background-color 0.12s ease, + color 0.12s ease; } .lesson-token.audio-sync-reading { @@ -1151,7 +1232,7 @@ footer { .phrase-item:hover { transform: translateX(5px); - box-shadow: 0 4px 8px rgba(0,0,0,0.1); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); } .copy-btn { @@ -1297,11 +1378,15 @@ footer { } .section-info::before { - content: ''; + content: ""; position: absolute; width: 200px; height: 200px; - background-image: radial-gradient(circle, var(--accent-color) 1px, transparent 1px); + background-image: radial-gradient( + circle, + var(--accent-color) 1px, + transparent 1px + ); background-size: 20px 20px; opacity: 0.1; z-index: 0; @@ -1347,7 +1432,7 @@ footer { .complete-btn:hover { transform: translateY(-2px); - box-shadow: 0 4px 8px rgba(0,0,0,0.1); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); } .complete-btn.completed { @@ -1363,24 +1448,36 @@ footer { color: white; padding: 10px 20px; border-radius: 8px; - box-shadow: 0 2px 4px rgba(0,0,0,0.2); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); display: none; animation: fadeInOut 2s ease; z-index: 1000; } @keyframes fadeInOut { - 0% { opacity: 0; transform: translateY(20px); } - 20% { opacity: 1; transform: translateY(0); } - 80% { opacity: 1; transform: translateY(0); } - 100% { opacity: 0; transform: translateY(-20px); } + 0% { + opacity: 0; + transform: translateY(20px); + } + 20% { + opacity: 1; + transform: translateY(0); + } + 80% { + opacity: 1; + transform: translateY(0); + } + 100% { + opacity: 0; + transform: translateY(-20px); + } } @media (max-width: 768px) { .language-selector { gap: 5px; } - + .language-btn { padding: 6px 10px; font-size: 0.9em; @@ -1403,7 +1500,8 @@ footer { align-items: stretch; } - .nav-btn, .home-btn { + .nav-btn, + .home-btn { width: 100%; justify-content: center; padding: 12px; @@ -1417,7 +1515,7 @@ footer { justify-content: flex-start; gap: 8px; } - + .language-btn { padding: 5px 8px; font-size: 0.8em; @@ -1431,7 +1529,7 @@ footer { width: 40px; height: 40px; } - + body { padding-bottom: 0; /* Remove padding since footer is no longer fixed */ } diff --git a/css/styles.min.css b/css/styles.min.css index dedadd5..5c496ec 100644 --- a/css/styles.min.css +++ b/css/styles.min.css @@ -1 +1,1316 @@ -@font-face{font-family:'Noto Sans SC';font-style:normal;font-weight:400;font-display:swap;src:local('Noto Sans SC Regular'),local('NotoSansSC-Regular'),url('https://fonts.gstatic.com/s/notosanssc/v12/k3kXo84MPvpLmixcA63oeALhLOCT-xWNm8Hqd37g1OkDRZe7lR4sg1IzSy-MNbE9VH8V.119.woff2') format('woff2');unicode-range:U+4E00-9FFF}:root{--primary-color:#1a2733;--secondary-color:#f8f9fa;--accent-color:#1a5c8f;--text-color:#1a2733;--hover-color:#134166;--success-color:#1e8449;--warning-color:#996600;--danger-color:#c0392b}body{font-family:Poppins,'Noto Sans SC',Arial,sans-serif;margin:0;padding:0;background-color:var(--secondary-color);color:var(--text-color);line-height:1.6;padding-bottom:60px}header{background:linear-gradient(135deg,var(--primary-color),#34495e);color:#fff;padding:2em 0;text-align:center;box-shadow:0 4px 6px rgba(0,0,0,.1)}header h1{margin:0;font-size:2.5em;font-weight:700;letter-spacing:1px}header p{margin:10px 0 0;font-size:1.2em;opacity:.9}.hero-section{background:url('https://images.unsplash.com/photo-1546410531-89436e5b419a?auto=format&fit=crop&w=1920&q=80') center/cover;padding:100px 20px;text-align:center;color:#fff;position:relative}.hero-section::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;background:rgba(44,62,80,.7)}.hero-content{position:relative;max-width:800px;margin:0 auto}.hero-content h2{font-size:2.8em;margin-bottom:20px;font-weight:700}.hero-content p{font-size:1.2em;margin-bottom:30px}main{padding:20px;max-width:1200px;margin:0 auto;position:relative}.visual-anchor{position:absolute;width:200px;height:200px;background-image:radial-gradient(circle,var(--accent-color) 1px,transparent 1px);background-size:20px 20px;opacity:.1;z-index:-1;border-radius:50%}.language-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:30px;padding:40px 20px;max-width:1200px;margin:0 auto}.language-card{background:#fff;border-radius:15px;overflow:hidden;box-shadow:0 10px 20px rgba(0,0,0,.1);transition:transform .3s ease,box-shadow .3s ease;cursor:pointer;position:relative}.language-card:hover{transform:translateY(-10px);box-shadow:0 15px 30px rgba(0,0,0,.15)}.language-card::after{content:attr(title);position:absolute;top:-40px;left:50%;transform:translateX(-50%);background:rgba(44,62,80,.9);color:#fff;padding:8px 12px;border-radius:6px;font-size:.85em;opacity:0;transition:opacity .3s ease,bottom .3s ease;pointer-events:none;white-space:nowrap;z-index:10}.language-card:hover::after{opacity:1;top:-50px}.card-header{padding:20px;background:linear-gradient(135deg,var(--accent-color),var(--hover-color));color:#fff}.card-header h3{margin:0;font-size:1.5em;display:flex;align-items:center;gap:10px}.flag{font-size:1.2em}.card-content{padding:20px}.progress-container{margin:15px 0}.progress-bar{height:8px;background:#ecf0f1;border-radius:4px;overflow:hidden}.progress-fill{height:100%;background:var(--success-color);width:0%;transition:width 1s ease}.stats{display:grid;grid-template-columns:repeat(2,1fr);gap:15px;margin-top:20px}.stat-item{text-align:center;padding:10px;background:var(--secondary-color);border-radius:8px}.stat-number{font-size:1.5em;font-weight:700;color:var(--accent-color)}.stat-label{font-size:.9em;color:#444}.section-divider{height:3px;background:linear-gradient(90deg,transparent,var(--accent-color),transparent);margin:20px auto 40px;width:80%;max-width:800px;opacity:.5;position:relative}.section-divider::before{content:'';position:absolute;width:30px;height:30px;background:#fff;border:3px solid var(--accent-color);border-radius:50%;top:50%;left:50%;transform:translate(-50%,-50%);opacity:.8}.course-overview{padding:40px 0;position:relative;overflow:hidden}.course-overview h2{text-align:center;margin-bottom:30px;color:var(--primary-color);font-size:2em}.course-sections{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:20px;padding:0 20px}.section-card{background:#fff;padding:25px;border-radius:12px;box-shadow:0 4px 6px rgba(0,0,0,.1);transition:transform .3s ease,box-shadow .3s ease;border-left:4px solid var(--accent-color)}.section-card:hover{transform:translateY(-5px);box-shadow:0 8px 15px rgba(0,0,0,.15)}.section-card h3{color:var(--accent-color);margin:0 0 15px 0;font-size:1.3em;display:flex;align-items:center;gap:10px}.section-card h3 i{font-size:1.2em;opacity:.9}.section-card p{margin:0;color:#444;font-size:.95em;line-height:1.5}#day-selection{margin:40px 0;position:relative;overflow:hidden}#day-selection h2{text-align:center;margin-bottom:30px;color:var(--primary-color);font-size:2em}.day-controls{display:flex;justify-content:center;margin-bottom:20px;gap:10px}.day-controls button{background:var(--accent-color);color:#fff;border:none;padding:8px 15px;border-radius:20px;cursor:pointer;font-weight:500;transition:all .3s ease;display:flex;align-items:center;gap:5px}.day-controls button:hover{background:var(--hover-color);transform:translateY(-2px)}.day-controls .day-range{display:flex;align-items:center;background:#fff;padding:8px 15px;border-radius:20px;font-weight:600;color:var(--primary-color);box-shadow:0 2px 4px rgba(0,0,0,.1)}.day-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));gap:15px;padding:20px}.day-grid a{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:20px 15px;background:#fff;color:var(--text-color);text-decoration:none;border-radius:12px;box-shadow:0 4px 6px rgba(0,0,0,.1);transition:all .3s ease;position:relative}.day-grid a:hover{transform:translateY(-5px);box-shadow:0 8px 15px rgba(0,0,0,.15);background:var(--accent-color);color:#fff}.day-grid a:hover::after{content:attr(title);position:absolute;top:-35px;left:50%;transform:translateX(-50%);background:rgba(44,62,80,.9);color:#fff;padding:6px 10px;border-radius:4px;font-size:.8em;white-space:nowrap;z-index:10}.day-number{font-size:1.5em;font-weight:700;margin-bottom:5px}.day-status{font-size:.8em;color:#444}.return-to-top{position:fixed;bottom:80px;right:30px;background:var(--primary-color);color:#fff;width:50px;height:50px;border-radius:50%;display:flex;justify-content:center;align-items:center;cursor:pointer;box-shadow:0 4px 10px rgba(0,0,0,.2);opacity:0;visibility:hidden;transition:all .3s ease;z-index:99}.return-to-top.visible{opacity:1;visibility:visible}.return-to-top:hover{background:var(--accent-color);transform:translateY(-5px)}footer{text-align:center;padding:1.5em 0;background:var(--primary-color);color:#fff;width:100%;z-index:100;position:relative}@media (max-width:768px){.hero-section{padding:60px 20px}.hero-content h2{font-size:2em}.language-cards{grid-template-columns:1fr;padding:20px}.course-sections{grid-template-columns:1fr}.day-grid{grid-template-columns:repeat(auto-fit,minmax(80px,1fr));gap:10px}.day-grid a{padding:15px 10px}.day-number{font-size:1.2em}.section-card{padding:20px}.section-card h3{font-size:1.2em}}.core-skills-section{padding:40px 0;position:relative;overflow:hidden}.core-skills-section h2{text-align:center;margin-bottom:15px;color:var(--primary-color);font-size:2em}.section-description{text-align:center;max-width:800px;margin:0 auto 30px;color:#444;font-size:1.1em;line-height:1.5}.core-skills-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(400px,1fr));gap:30px;padding:0 20px}.core-skill-card{background:#fff;padding:30px;border-radius:15px;box-shadow:0 6px 12px rgba(0,0,0,.15);transition:transform .3s ease,box-shadow .3s ease;text-decoration:none;color:var(--text-color);position:relative;overflow:hidden;border-left:5px solid var(--accent-color);display:flex;flex-direction:column}.core-skill-card:hover{transform:translateY(-10px);box-shadow:0 12px 24px rgba(0,0,0,.2)}.core-skill-card .card-icon{width:70px;height:70px;background:var(--accent-color);border-radius:50%;display:flex;align-items:center;justify-content:center;margin-bottom:20px;color:#fff;font-size:1.8em;position:relative;z-index:1}.core-skill-card h3{color:var(--primary-color);margin:0 0 15px 0;font-size:1.5em;position:relative;z-index:1}.core-skill-card p{margin:0;color:#555;font-size:1em;line-height:1.6;position:relative;z-index:1}.core-skill-card::before{content:'';position:absolute;width:250px;height:250px;background-image:radial-gradient(circle,var(--accent-color) 1px,transparent 1px);background-size:20px 20px;opacity:.1;z-index:0;border-radius:50%;right:-125px;top:-125px}@media (max-width:768px){.core-skills-grid{grid-template-columns:1fr}.core-skill-card{padding:25px}.core-skill-card .card-icon{width:60px;height:60px;font-size:1.5em}.core-skill-card h3{font-size:1.3em}}.supplementary-section{padding:40px 0;position:relative;overflow:hidden}.supplementary-section h2{text-align:center;margin-bottom:30px;color:var(--primary-color);font-size:2em}.supplementary-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:25px;padding:0 20px}.supplementary-card{background:#fff;padding:25px;border-radius:12px;box-shadow:0 4px 6px rgba(0,0,0,.1);text-decoration:none;color:var(--text-color);position:relative;overflow:hidden}[lang=en] .zh{display:none}[lang=zh-CN] .en{display:none}.zh{font-family:'Noto Sans SC',sans-serif}.language-selector{display:flex;justify-content:center;gap:10px;margin-bottom:20px}.language-btn{padding:8px 15px;border:none;border-radius:20px;background:rgba(255,255,255,.1);color:#fff;cursor:pointer;transition:all .3s ease;display:flex;align-items:center;gap:5px;font-size:.9em}.language-btn.active,.language-btn:hover{background:rgba(255,255,255,.3);transform:translateY(-2px);box-shadow:0 2px 4px rgba(0,0,0,.2);font-weight:600}@media (max-width:768px){.language-selector{flex-wrap:wrap;gap:5px}.language-btn{padding:6px 10px;font-size:.85em}}.supplementary-card::before{content:'';position:absolute;width:200px;height:200px;background-image:radial-gradient(circle,var(--accent-color) 1px,transparent 1px);background-size:20px 20px;opacity:.1;z-index:0;border-radius:50%;right:-100px;top:-100px}.supplementary-card:hover{transform:translateY(-10px);box-shadow:0 8px 15px rgba(0,0,0,.15)}.card-icon{width:60px;height:60px;background:var(--accent-color);border-radius:50%;display:flex;align-items:center;justify-content:center;margin-bottom:15px;color:#fff;font-size:1.5em;position:relative;z-index:1}.supplementary-card h3{color:var(--accent-color);margin:0 0 10px 0;font-size:1.3em;position:relative;z-index:1}.supplementary-card p{margin:0;color:#444;font-size:.95em;line-height:1.5;position:relative;z-index:1}.benefits-section{padding:40px 0;background:var(--secondary-color);position:relative;overflow:hidden}.journey-path{position:absolute;top:0;left:50%;height:100%;width:4px;background:linear-gradient(to bottom,transparent,var(--accent-color),transparent);opacity:.2;z-index:0}.journey-path::after,.journey-path::before{content:'';position:absolute;width:20px;height:20px;border-radius:50%;background:var(--accent-color);left:50%;transform:translateX(-50%);opacity:.4}.journey-path::before{top:10%}.journey-path::after{bottom:10%}.benefits-section h2{text-align:center;margin-bottom:30px;color:var(--primary-color);font-size:2em}.benefits-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:25px;padding:0 20px}.benefit-card{background:#fff;padding:25px;border-radius:12px;box-shadow:0 4px 6px rgba(0,0,0,.1);transition:transform .3s ease}.benefit-card:hover{transform:translateY(-5px);box-shadow:0 8px 15px rgba(0,0,0,.15)}.benefit-card h3{color:var(--accent-color);margin:0 0 20px 0;font-size:1.3em;display:flex;align-items:center;gap:10px}.benefit-card h3 i{font-size:1.2em;opacity:.9}.benefit-card ul{list-style:none;padding:0;margin:0}.benefit-card ul li{margin-bottom:12px;padding-left:20px;position:relative}.benefit-card ul li:before{content:"•";color:var(--accent-color);position:absolute;left:0}.combo-table{margin-top:15px}.combo-row{display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #eee}.combo-row:last-child{border-bottom:none}.combo{font-weight:600;color:var(--primary-color)}.benefit{color:var(--accent-color);text-align:right}@media (max-width:768px){.benefits-grid{grid-template-columns:1fr}.combo-row{flex-direction:column;align-items:flex-start;gap:5px}.benefit{text-align:left}}@media (max-width:480px){header h1{font-size:2em}.hero-content h2{font-size:1.8em}.hero-content p{font-size:1em}body{padding-bottom:80px}.language-card::after{display:none}}@keyframes fadeIn{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}@keyframes pulse{0%{transform:scale(1)}50%{transform:scale(1.05)}100%{transform:scale(1)}}@keyframes float{0%{transform:translateY(0)}50%{transform:translateY(-10px)}100%{transform:translateY(0)}}@keyframes slideInRight{from{opacity:0;transform:translateX(50px)}to{opacity:1;transform:translateX(0)}}@keyframes slideInLeft{from{opacity:0;transform:translateX(-50px)}to{opacity:1;transform:translateX(0)}}.animate-fade-in{animation:fadeIn .8s ease forwards}.animate-pulse{animation:pulse 2s ease-in-out infinite}.animate-float{animation:float 3s ease-in-out infinite}.animate-slide-right{animation:slideInRight .8s ease forwards}.animate-slide-left{animation:slideInLeft .8s ease forwards}.hover-scale{transition:transform .3s ease}.hover-scale:hover{transform:scale(1.05)}.lesson-container{background:#fff;border-radius:15px;padding:30px;box-shadow:0 4px 6px rgba(0,0,0,.1);margin:20px 0;position:relative;animation:fadeIn .8s ease forwards}.lesson-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}.lesson-title{display:flex;align-items:center;gap:10px}.lesson-title .flag{font-size:1.5em}.language-selector{display:flex;gap:10px}.language-btn{padding:8px 15px;border:none;border-radius:8px;background:var(--secondary-color);color:var(--text-color);cursor:pointer;transition:all .3s ease;display:flex;align-items:center;gap:5px}.language-btn.active,.language-btn:hover{background:var(--accent-color);color:#fff;transform:translateY(-2px)}.audio-player{width:100%;margin:20px 0;padding:15px;background:var(--secondary-color);border-radius:10px;animation:fadeIn .8s ease forwards}.audio-player audio{width:100%}#audio-fallback{margin-top:10px}#audio-fallback .note{background-color:rgba(52,152,219,.1);border-left:4px solid var(--accent-color);padding:10px 15px;border-radius:4px;font-size:.9em;color:var(--primary-color);display:flex;align-items:center;gap:8px}.text-content{margin:20px 0;line-height:1.8;font-size:1.1em;animation:fadeIn .8s ease forwards}.phrase-section{margin-bottom:30px;background:var(--secondary-color);padding:20px;border-radius:10px;animation:fadeIn .8s ease forwards;position:relative;overflow:hidden}.phrase-section::before{content:'';position:absolute;width:200px;height:200px;background-image:radial-gradient(circle,var(--accent-color) 1px,transparent 1px);background-size:20px 20px;opacity:.1;z-index:0;border-radius:50%;right:-100px;top:-100px}.phrase-section h3{color:var(--accent-color);margin:0 0 15px 0;font-size:1.3em;border-bottom:2px solid var(--accent-color);padding-bottom:5px;position:relative;z-index:1}.phrase-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:15px;position:relative;z-index:1}.phrase-item{background:#fff;padding:12px;border-radius:8px;display:flex;justify-content:space-between;align-items:center;gap:8px;transition:all .3s ease;box-shadow:0 2px 4px rgba(0,0,0,.05)}.phrase-reading{flex:1;min-width:0;line-height:1.55}.phrase-item.audio-sync-active{box-shadow:0 0 0 2px rgba(26,92,143,.35),0 4px 8px rgba(0,0,0,.1);background-color:#f9fcff}.lesson-token{transition:background-color .12s ease,color .12s ease}.lesson-token.audio-sync-reading{background-color:rgba(243,156,18,.35);border-radius:2px}.lesson-space{pointer-events:none}.reading-sync-line{margin:.75rem 0;line-height:1.65}.reading-sync-line.audio-sync-active,.writing-sync-cue.audio-sync-active{background-color:rgba(249,252,255,.95);box-shadow:0 0 0 2px rgba(26,92,143,.3);border-radius:4px}.writing-sync-cue{display:block;margin-bottom:.35rem}.reading-passage-reading{display:inline}.phrase-item:hover{transform:translateX(5px);box-shadow:0 4px 8px rgba(0,0,0,.1)}.copy-btn{background:0 0;border:none;color:var(--accent-color);cursor:pointer;padding:5px;opacity:.7;transition:all .3s ease}.copy-btn:hover{opacity:1;transform:scale(1.1)}.phrase-star-btn{background:0 0;border:none;color:var(--warning-color);cursor:pointer;padding:5px;opacity:.85;transition:all .3s ease}.phrase-star-btn:hover{opacity:1;transform:scale(1.1)}.phrase-star-btn[aria-pressed=true]{color:#f39c12}.review-card{border:1px solid var(--secondary-color);border-radius:8px;padding:1rem;margin-bottom:1rem;background:#fff}.review-phrase{font-size:1.15rem;margin:0 0 .5rem}.review-meta{margin:0 0 .75rem;font-size:.9rem;color:#555}.review-actions{display:flex;flex-wrap:wrap;gap:.5rem;align-items:center}.secondary-btn{background:#f0f0f0;border:1px solid #ddd;padding:.5rem 1rem;border-radius:4px;cursor:pointer}.site-nav-links{margin:.5rem 0 0;text-align:center}.site-nav-links a{color:inherit;font-weight:600;text-decoration:underline}.navigation{display:flex;justify-content:space-between;margin:20px 0;animation:fadeIn .8s ease forwards}.navigation:last-child{margin-top:30px;padding-top:20px;border-top:1px solid var(--secondary-color)}.nav-btn{padding:10px 20px;border:none;border-radius:8px;background:var(--accent-color);color:#fff;text-decoration:none;display:flex;align-items:center;gap:8px;transition:all .3s ease}.nav-btn:hover{background:var(--hover-color);transform:translateY(-2px)}.nav-btn.disabled{opacity:.5;cursor:not-allowed}.home-btn{padding:10px 20px;background:var(--primary-color);color:#fff;text-decoration:none;border-radius:8px;display:inline-flex;align-items:center;gap:8px;transition:all .3s ease}.home-btn:hover{background:#34495e;transform:translateY(-2px)}.section-info{background:var(--secondary-color);padding:15px;border-radius:8px;margin:20px 0;animation:fadeIn .8s ease forwards;position:relative;overflow:hidden}.section-info::before{content:'';position:absolute;width:200px;height:200px;background-image:radial-gradient(circle,var(--accent-color) 1px,transparent 1px);background-size:20px 20px;opacity:.1;z-index:0;border-radius:50%;left:-100px;bottom:-100px}.section-info h3{color:var(--accent-color);margin:0 0 10px 0;position:relative;z-index:1}.section-info p{margin:0;color:#444;position:relative;z-index:1}.lesson-actions{display:flex;justify-content:center;margin:30px 0;animation:fadeIn .8s ease forwards}.complete-btn{padding:12px 24px;border:none;border-radius:8px;background:var(--success-color);color:#fff;font-size:1.1em;cursor:pointer;display:flex;align-items:center;gap:8px;transition:all .3s ease}.complete-btn:hover{transform:translateY(-2px);box-shadow:0 4px 8px rgba(0,0,0,.1)}.complete-btn.completed{background:var(--accent-color);cursor:default}.copy-notification{position:fixed;bottom:20px;right:20px;background:var(--success-color);color:#fff;padding:10px 20px;border-radius:8px;box-shadow:0 2px 4px rgba(0,0,0,.2);display:none;animation:fadeInOut 2s ease;z-index:1000}@keyframes fadeInOut{0%{opacity:0;transform:translateY(20px)}20%{opacity:1;transform:translateY(0)}80%{opacity:1;transform:translateY(0)}100%{opacity:0;transform:translateY(-20px)}}@media (max-width:768px){.language-selector{gap:5px}.language-btn{padding:6px 10px;font-size:.9em}.lesson-header{flex-direction:column;align-items:flex-start;gap:15px}.language-selector{width:100%;justify-content:flex-start}.navigation{flex-direction:column;gap:10px;align-items:stretch}.home-btn,.nav-btn{width:100%;justify-content:center;padding:12px;font-size:.9em}}@media (max-width:480px){.language-selector{flex-wrap:wrap;justify-content:flex-start;gap:8px}.language-btn{padding:5px 8px;font-size:.8em}}@media (max-width:768px){.return-to-top{bottom:70px;right:20px;width:40px;height:40px}body{padding-bottom:0}} \ No newline at end of file +@font-face { + font-family: "Noto Sans SC"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: + local("Noto Sans SC Regular"), + local("NotoSansSC-Regular"), + url("https://fonts.gstatic.com/s/notosanssc/v12/k3kXo84MPvpLmixcA63oeALhLOCT-xWNm8Hqd37g1OkDRZe7lR4sg1IzSy-MNbE9VH8V.119.woff2") + format("woff2"); + unicode-range: U+4E00-9FFF; +} +:root { + --primary-color: #1a2733; + --secondary-color: #f8f9fa; + --accent-color: #1a5c8f; + --text-color: #1a2733; + --hover-color: #134166; + --success-color: #1e8449; + --warning-color: #996600; + --danger-color: #c0392b; +} +body { + font-family: Poppins, "Noto Sans SC", Arial, sans-serif; + margin: 0; + padding: 0; + background-color: var(--secondary-color); + color: var(--text-color); + line-height: 1.6; + padding-bottom: 60px; +} +header { + background: linear-gradient(135deg, var(--primary-color), #34495e); + color: #fff; + padding: 2em 0; + text-align: center; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} +header h1 { + margin: 0; + font-size: 2.5em; + font-weight: 700; + letter-spacing: 1px; +} +header p { + margin: 10px 0 0; + font-size: 1.2em; + opacity: 0.9; +} +.hero-section { + background: url("https://images.unsplash.com/photo-1546410531-89436e5b419a?auto=format&fit=crop&w=1920&q=80") + center/cover; + padding: 100px 20px; + text-align: center; + color: #fff; + position: relative; +} +.hero-section::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(44, 62, 80, 0.7); +} +.hero-content { + position: relative; + max-width: 800px; + margin: 0 auto; +} +.hero-content h2 { + font-size: 2.8em; + margin-bottom: 20px; + font-weight: 700; +} +.hero-content p { + font-size: 1.2em; + margin-bottom: 30px; +} +main { + padding: 20px; + max-width: 1200px; + margin: 0 auto; + position: relative; +} +.visual-anchor { + position: absolute; + width: 200px; + height: 200px; + background-image: radial-gradient( + circle, + var(--accent-color) 1px, + transparent 1px + ); + background-size: 20px 20px; + opacity: 0.1; + z-index: -1; + border-radius: 50%; +} +.language-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 30px; + padding: 40px 20px; + max-width: 1200px; + margin: 0 auto; +} +.language-card { + background: #fff; + border-radius: 15px; + overflow: hidden; + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1); + transition: + transform 0.3s ease, + box-shadow 0.3s ease; + cursor: pointer; + position: relative; +} +.language-card:hover { + transform: translateY(-10px); + box-shadow: 0 15px 30px rgba(0, 0, 0, 0.15); +} +.language-card::after { + content: attr(title); + position: absolute; + top: -40px; + left: 50%; + transform: translateX(-50%); + background: rgba(44, 62, 80, 0.9); + color: #fff; + padding: 8px 12px; + border-radius: 6px; + font-size: 0.85em; + opacity: 0; + transition: + opacity 0.3s ease, + bottom 0.3s ease; + pointer-events: none; + white-space: nowrap; + z-index: 10; +} +.language-card:hover::after { + opacity: 1; + top: -50px; +} +.card-header { + padding: 20px; + background: linear-gradient( + 135deg, + var(--accent-color), + var(--hover-color) + ); + color: #fff; +} +.card-header h3 { + margin: 0; + font-size: 1.5em; + display: flex; + align-items: center; + gap: 10px; +} +.flag { + font-size: 1.2em; +} +.card-content { + padding: 20px; +} +.progress-container { + margin: 15px 0; +} +.progress-bar { + height: 8px; + background: #ecf0f1; + border-radius: 4px; + overflow: hidden; +} +.progress-fill { + height: 100%; + background: var(--success-color); + width: 0%; + transition: width 1s ease; +} +.stats { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 15px; + margin-top: 20px; +} +.stat-item { + text-align: center; + padding: 10px; + background: var(--secondary-color); + border-radius: 8px; +} +.stat-number { + font-size: 1.5em; + font-weight: 700; + color: var(--accent-color); +} +.stat-label { + font-size: 0.9em; + color: #444; +} +.section-divider { + height: 3px; + background: linear-gradient( + 90deg, + transparent, + var(--accent-color), + transparent + ); + margin: 20px auto 40px; + width: 80%; + max-width: 800px; + opacity: 0.5; + position: relative; +} +.section-divider::before { + content: ""; + position: absolute; + width: 30px; + height: 30px; + background: #fff; + border: 3px solid var(--accent-color); + border-radius: 50%; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + opacity: 0.8; +} +.course-overview { + padding: 40px 0; + position: relative; + overflow: hidden; +} +.course-overview h2 { + text-align: center; + margin-bottom: 30px; + color: var(--primary-color); + font-size: 2em; +} +.course-sections { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 20px; + padding: 0 20px; +} +.section-card { + background: #fff; + padding: 25px; + border-radius: 12px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + transition: + transform 0.3s ease, + box-shadow 0.3s ease; + border-left: 4px solid var(--accent-color); +} +.section-card:hover { + transform: translateY(-5px); + box-shadow: 0 8px 15px rgba(0, 0, 0, 0.15); +} +.section-card h3 { + color: var(--accent-color); + margin: 0 0 15px 0; + font-size: 1.3em; + display: flex; + align-items: center; + gap: 10px; +} +.section-card h3 i { + font-size: 1.2em; + opacity: 0.9; +} +.section-card p { + margin: 0; + color: #444; + font-size: 0.95em; + line-height: 1.5; +} +#day-selection { + margin: 40px 0; + position: relative; + overflow: hidden; +} +#day-selection h2 { + text-align: center; + margin-bottom: 30px; + color: var(--primary-color); + font-size: 2em; +} +.day-controls { + display: flex; + justify-content: center; + margin-bottom: 20px; + gap: 10px; +} +.day-controls button { + background: var(--accent-color); + color: #fff; + border: none; + padding: 8px 15px; + border-radius: 20px; + cursor: pointer; + font-weight: 500; + transition: all 0.3s ease; + display: flex; + align-items: center; + gap: 5px; +} +.day-controls button:hover { + background: var(--hover-color); + transform: translateY(-2px); +} +.day-controls .day-range { + display: flex; + align-items: center; + background: #fff; + padding: 8px 15px; + border-radius: 20px; + font-weight: 600; + color: var(--primary-color); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} +.day-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); + gap: 15px; + padding: 20px; +} +.day-grid a { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px 15px; + background: #fff; + color: var(--text-color); + text-decoration: none; + border-radius: 12px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + transition: all 0.3s ease; + position: relative; +} +.day-grid a:hover { + transform: translateY(-5px); + box-shadow: 0 8px 15px rgba(0, 0, 0, 0.15); + background: var(--accent-color); + color: #fff; +} +.day-grid a:hover::after { + content: attr(title); + position: absolute; + top: -35px; + left: 50%; + transform: translateX(-50%); + background: rgba(44, 62, 80, 0.9); + color: #fff; + padding: 6px 10px; + border-radius: 4px; + font-size: 0.8em; + white-space: nowrap; + z-index: 10; +} +.day-number { + font-size: 1.5em; + font-weight: 700; + margin-bottom: 5px; +} +.day-status { + font-size: 0.8em; + color: #444; +} +.return-to-top { + position: fixed; + bottom: 80px; + right: 30px; + background: var(--primary-color); + color: #fff; + width: 50px; + height: 50px; + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2); + opacity: 0; + visibility: hidden; + transition: all 0.3s ease; + z-index: 99; +} +.return-to-top.visible { + opacity: 1; + visibility: visible; +} +.return-to-top:hover { + background: var(--accent-color); + transform: translateY(-5px); +} +footer { + text-align: center; + padding: 1.5em 0; + background: var(--primary-color); + color: #fff; + width: 100%; + z-index: 100; + position: relative; +} +@media (max-width: 768px) { + .hero-section { + padding: 60px 20px; + } + .hero-content h2 { + font-size: 2em; + } + .language-cards { + grid-template-columns: 1fr; + padding: 20px; + } + .course-sections { + grid-template-columns: 1fr; + } + .day-grid { + grid-template-columns: repeat(auto-fit, minmax(80px, 1fr)); + gap: 10px; + } + .day-grid a { + padding: 15px 10px; + } + .day-number { + font-size: 1.2em; + } + .section-card { + padding: 20px; + } + .section-card h3 { + font-size: 1.2em; + } +} +.core-skills-section { + padding: 40px 0; + position: relative; + overflow: hidden; +} +.core-skills-section h2 { + text-align: center; + margin-bottom: 15px; + color: var(--primary-color); + font-size: 2em; +} +.section-description { + text-align: center; + max-width: 800px; + margin: 0 auto 30px; + color: #444; + font-size: 1.1em; + line-height: 1.5; +} +.core-skills-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); + gap: 30px; + padding: 0 20px; +} +.core-skill-card { + background: #fff; + padding: 30px; + border-radius: 15px; + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15); + transition: + transform 0.3s ease, + box-shadow 0.3s ease; + text-decoration: none; + color: var(--text-color); + position: relative; + overflow: hidden; + border-left: 5px solid var(--accent-color); + display: flex; + flex-direction: column; +} +.core-skill-card:hover { + transform: translateY(-10px); + box-shadow: 0 12px 24px rgba(0, 0, 0, 0.2); +} +.core-skill-card .card-icon { + width: 70px; + height: 70px; + background: var(--accent-color); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 20px; + color: #fff; + font-size: 1.8em; + position: relative; + z-index: 1; +} +.core-skill-card h3 { + color: var(--primary-color); + margin: 0 0 15px 0; + font-size: 1.5em; + position: relative; + z-index: 1; +} +.core-skill-card p { + margin: 0; + color: #555; + font-size: 1em; + line-height: 1.6; + position: relative; + z-index: 1; +} +.core-skill-card::before { + content: ""; + position: absolute; + width: 250px; + height: 250px; + background-image: radial-gradient( + circle, + var(--accent-color) 1px, + transparent 1px + ); + background-size: 20px 20px; + opacity: 0.1; + z-index: 0; + border-radius: 50%; + right: -125px; + top: -125px; +} +@media (max-width: 768px) { + .core-skills-grid { + grid-template-columns: 1fr; + } + .core-skill-card { + padding: 25px; + } + .core-skill-card .card-icon { + width: 60px; + height: 60px; + font-size: 1.5em; + } + .core-skill-card h3 { + font-size: 1.3em; + } +} +.supplementary-section { + padding: 40px 0; + position: relative; + overflow: hidden; +} +.supplementary-section h2 { + text-align: center; + margin-bottom: 30px; + color: var(--primary-color); + font-size: 2em; +} +.supplementary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 25px; + padding: 0 20px; +} +.supplementary-card { + background: #fff; + padding: 25px; + border-radius: 12px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + text-decoration: none; + color: var(--text-color); + position: relative; + overflow: hidden; +} +[lang="en"] .zh { + display: none; +} +[lang="zh-CN"] .en { + display: none; +} +.zh { + font-family: "Noto Sans SC", sans-serif; +} +.language-selector { + display: flex; + justify-content: center; + gap: 10px; + margin-bottom: 20px; +} +.language-btn { + padding: 8px 15px; + border: none; + border-radius: 20px; + background: rgba(255, 255, 255, 0.1); + color: #fff; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + gap: 5px; + font-size: 0.9em; +} +.language-btn.active, +.language-btn:hover { + background: rgba(255, 255, 255, 0.3); + transform: translateY(-2px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + font-weight: 600; +} +@media (max-width: 768px) { + .language-selector { + flex-wrap: wrap; + gap: 5px; + } + .language-btn { + padding: 6px 10px; + font-size: 0.85em; + } +} +.supplementary-card::before { + content: ""; + position: absolute; + width: 200px; + height: 200px; + background-image: radial-gradient( + circle, + var(--accent-color) 1px, + transparent 1px + ); + background-size: 20px 20px; + opacity: 0.1; + z-index: 0; + border-radius: 50%; + right: -100px; + top: -100px; +} +.supplementary-card:hover { + transform: translateY(-10px); + box-shadow: 0 8px 15px rgba(0, 0, 0, 0.15); +} +.card-icon { + width: 60px; + height: 60px; + background: var(--accent-color); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 15px; + color: #fff; + font-size: 1.5em; + position: relative; + z-index: 1; +} +.supplementary-card h3 { + color: var(--accent-color); + margin: 0 0 10px 0; + font-size: 1.3em; + position: relative; + z-index: 1; +} +.supplementary-card p { + margin: 0; + color: #444; + font-size: 0.95em; + line-height: 1.5; + position: relative; + z-index: 1; +} +.benefits-section { + padding: 40px 0; + background: var(--secondary-color); + position: relative; + overflow: hidden; +} +.journey-path { + position: absolute; + top: 0; + left: 50%; + height: 100%; + width: 4px; + background: linear-gradient( + to bottom, + transparent, + var(--accent-color), + transparent + ); + opacity: 0.2; + z-index: 0; +} +.journey-path::after, +.journey-path::before { + content: ""; + position: absolute; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--accent-color); + left: 50%; + transform: translateX(-50%); + opacity: 0.4; +} +.journey-path::before { + top: 10%; +} +.journey-path::after { + bottom: 10%; +} +.benefits-section h2 { + text-align: center; + margin-bottom: 30px; + color: var(--primary-color); + font-size: 2em; +} +.benefits-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 25px; + padding: 0 20px; +} +.benefit-card { + background: #fff; + padding: 25px; + border-radius: 12px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + transition: transform 0.3s ease; +} +.benefit-card:hover { + transform: translateY(-5px); + box-shadow: 0 8px 15px rgba(0, 0, 0, 0.15); +} +.benefit-card h3 { + color: var(--accent-color); + margin: 0 0 20px 0; + font-size: 1.3em; + display: flex; + align-items: center; + gap: 10px; +} +.benefit-card h3 i { + font-size: 1.2em; + opacity: 0.9; +} +.benefit-card ul { + list-style: none; + padding: 0; + margin: 0; +} +.benefit-card ul li { + margin-bottom: 12px; + padding-left: 20px; + position: relative; +} +.benefit-card ul li:before { + content: "•"; + color: var(--accent-color); + position: absolute; + left: 0; +} +.combo-table { + margin-top: 15px; +} +.combo-row { + display: flex; + justify-content: space-between; + padding: 8px 0; + border-bottom: 1px solid #eee; +} +.combo-row:last-child { + border-bottom: none; +} +.combo { + font-weight: 600; + color: var(--primary-color); +} +.benefit { + color: var(--accent-color); + text-align: right; +} +@media (max-width: 768px) { + .benefits-grid { + grid-template-columns: 1fr; + } + .combo-row { + flex-direction: column; + align-items: flex-start; + gap: 5px; + } + .benefit { + text-align: left; + } +} +@media (max-width: 480px) { + header h1 { + font-size: 2em; + } + .hero-content h2 { + font-size: 1.8em; + } + .hero-content p { + font-size: 1em; + } + body { + padding-bottom: 80px; + } + .language-card::after { + display: none; + } +} +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +@keyframes pulse { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.05); + } + 100% { + transform: scale(1); + } +} +@keyframes float { + 0% { + transform: translateY(0); + } + 50% { + transform: translateY(-10px); + } + 100% { + transform: translateY(0); + } +} +@keyframes slideInRight { + from { + opacity: 0; + transform: translateX(50px); + } + to { + opacity: 1; + transform: translateX(0); + } +} +@keyframes slideInLeft { + from { + opacity: 0; + transform: translateX(-50px); + } + to { + opacity: 1; + transform: translateX(0); + } +} +.animate-fade-in { + animation: fadeIn 0.8s ease forwards; +} +.animate-pulse { + animation: pulse 2s ease-in-out infinite; +} +.animate-float { + animation: float 3s ease-in-out infinite; +} +.animate-slide-right { + animation: slideInRight 0.8s ease forwards; +} +.animate-slide-left { + animation: slideInLeft 0.8s ease forwards; +} +.hover-scale { + transition: transform 0.3s ease; +} +.hover-scale:hover { + transform: scale(1.05); +} +.lesson-container { + background: #fff; + border-radius: 15px; + padding: 30px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + margin: 20px 0; + position: relative; + animation: fadeIn 0.8s ease forwards; +} +.lesson-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} +.lesson-title { + display: flex; + align-items: center; + gap: 10px; +} +.lesson-title .flag { + font-size: 1.5em; +} +.language-selector { + display: flex; + gap: 10px; +} +.language-btn { + padding: 8px 15px; + border: none; + border-radius: 8px; + background: var(--secondary-color); + color: var(--text-color); + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + gap: 5px; +} +.language-btn.active, +.language-btn:hover { + background: var(--accent-color); + color: #fff; + transform: translateY(-2px); +} +.audio-player { + width: 100%; + margin: 20px 0; + padding: 15px; + background: var(--secondary-color); + border-radius: 10px; + animation: fadeIn 0.8s ease forwards; +} +.audio-player audio { + width: 100%; +} +#audio-fallback { + margin-top: 10px; +} +#audio-fallback .note { + background-color: rgba(52, 152, 219, 0.1); + border-left: 4px solid var(--accent-color); + padding: 10px 15px; + border-radius: 4px; + font-size: 0.9em; + color: var(--primary-color); + display: flex; + align-items: center; + gap: 8px; +} +.text-content { + margin: 20px 0; + line-height: 1.8; + font-size: 1.1em; + animation: fadeIn 0.8s ease forwards; +} +.phrase-section { + margin-bottom: 30px; + background: var(--secondary-color); + padding: 20px; + border-radius: 10px; + animation: fadeIn 0.8s ease forwards; + position: relative; + overflow: hidden; +} +.phrase-section::before { + content: ""; + position: absolute; + width: 200px; + height: 200px; + background-image: radial-gradient( + circle, + var(--accent-color) 1px, + transparent 1px + ); + background-size: 20px 20px; + opacity: 0.1; + z-index: 0; + border-radius: 50%; + right: -100px; + top: -100px; +} +.phrase-section h3 { + color: var(--accent-color); + margin: 0 0 15px 0; + font-size: 1.3em; + border-bottom: 2px solid var(--accent-color); + padding-bottom: 5px; + position: relative; + z-index: 1; +} +.phrase-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 15px; + position: relative; + z-index: 1; +} +.phrase-item { + background: #fff; + padding: 12px; + border-radius: 8px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + transition: all 0.3s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); +} +.phrase-reading { + flex: 1; + min-width: 0; + line-height: 1.55; +} +.phrase-item.audio-sync-active { + box-shadow: + 0 0 0 2px rgba(26, 92, 143, 0.35), + 0 4px 8px rgba(0, 0, 0, 0.1); + background-color: #f9fcff; +} +.lesson-token { + transition: + background-color 0.12s ease, + color 0.12s ease; +} +.lesson-token.audio-sync-reading { + background-color: rgba(243, 156, 18, 0.35); + border-radius: 2px; +} +.lesson-space { + pointer-events: none; +} +.reading-sync-line { + margin: 0.75rem 0; + line-height: 1.65; +} +.reading-sync-line.audio-sync-active, +.writing-sync-cue.audio-sync-active { + background-color: rgba(249, 252, 255, 0.95); + box-shadow: 0 0 0 2px rgba(26, 92, 143, 0.3); + border-radius: 4px; +} +.writing-sync-cue { + display: block; + margin-bottom: 0.35rem; +} +.reading-passage-reading { + display: inline; +} +.phrase-item:hover { + transform: translateX(5px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} +.copy-btn { + background: 0 0; + border: none; + color: var(--accent-color); + cursor: pointer; + padding: 5px; + opacity: 0.7; + transition: all 0.3s ease; +} +.copy-btn:hover { + opacity: 1; + transform: scale(1.1); +} +.phrase-star-btn { + background: 0 0; + border: none; + color: var(--warning-color); + cursor: pointer; + padding: 5px; + opacity: 0.85; + transition: all 0.3s ease; +} +.phrase-star-btn:hover { + opacity: 1; + transform: scale(1.1); +} +.phrase-star-btn[aria-pressed="true"] { + color: #f39c12; +} +.review-card { + border: 1px solid var(--secondary-color); + border-radius: 8px; + padding: 1rem; + margin-bottom: 1rem; + background: #fff; +} +.review-phrase { + font-size: 1.15rem; + margin: 0 0 0.5rem; +} +.review-meta { + margin: 0 0 0.75rem; + font-size: 0.9rem; + color: #555; +} +.review-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} +.secondary-btn { + background: #f0f0f0; + border: 1px solid #ddd; + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; +} +.site-nav-links { + margin: 0.5rem 0 0; + text-align: center; +} +.site-nav-links a { + color: inherit; + font-weight: 600; + text-decoration: underline; +} +.navigation { + display: flex; + justify-content: space-between; + margin: 20px 0; + animation: fadeIn 0.8s ease forwards; +} +.navigation:last-child { + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid var(--secondary-color); +} +.nav-btn { + padding: 10px 20px; + border: none; + border-radius: 8px; + background: var(--accent-color); + color: #fff; + text-decoration: none; + display: flex; + align-items: center; + gap: 8px; + transition: all 0.3s ease; +} +.nav-btn:hover { + background: var(--hover-color); + transform: translateY(-2px); +} +.nav-btn.disabled { + opacity: 0.5; + cursor: not-allowed; +} +.home-btn { + padding: 10px 20px; + background: var(--primary-color); + color: #fff; + text-decoration: none; + border-radius: 8px; + display: inline-flex; + align-items: center; + gap: 8px; + transition: all 0.3s ease; +} +.home-btn:hover { + background: #34495e; + transform: translateY(-2px); +} +.section-info { + background: var(--secondary-color); + padding: 15px; + border-radius: 8px; + margin: 20px 0; + animation: fadeIn 0.8s ease forwards; + position: relative; + overflow: hidden; +} +.section-info::before { + content: ""; + position: absolute; + width: 200px; + height: 200px; + background-image: radial-gradient( + circle, + var(--accent-color) 1px, + transparent 1px + ); + background-size: 20px 20px; + opacity: 0.1; + z-index: 0; + border-radius: 50%; + left: -100px; + bottom: -100px; +} +.section-info h3 { + color: var(--accent-color); + margin: 0 0 10px 0; + position: relative; + z-index: 1; +} +.section-info p { + margin: 0; + color: #444; + position: relative; + z-index: 1; +} +.lesson-actions { + display: flex; + justify-content: center; + margin: 30px 0; + animation: fadeIn 0.8s ease forwards; +} +.complete-btn { + padding: 12px 24px; + border: none; + border-radius: 8px; + background: var(--success-color); + color: #fff; + font-size: 1.1em; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + transition: all 0.3s ease; +} +.complete-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} +.complete-btn.completed { + background: var(--accent-color); + cursor: default; +} +.copy-notification { + position: fixed; + bottom: 20px; + right: 20px; + background: var(--success-color); + color: #fff; + padding: 10px 20px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + display: none; + animation: fadeInOut 2s ease; + z-index: 1000; +} +@keyframes fadeInOut { + 0% { + opacity: 0; + transform: translateY(20px); + } + 20% { + opacity: 1; + transform: translateY(0); + } + 80% { + opacity: 1; + transform: translateY(0); + } + 100% { + opacity: 0; + transform: translateY(-20px); + } +} +@media (max-width: 768px) { + .language-selector { + gap: 5px; + } + .language-btn { + padding: 6px 10px; + font-size: 0.9em; + } + .lesson-header { + flex-direction: column; + align-items: flex-start; + gap: 15px; + } + .language-selector { + width: 100%; + justify-content: flex-start; + } + .navigation { + flex-direction: column; + gap: 10px; + align-items: stretch; + } + .home-btn, + .nav-btn { + width: 100%; + justify-content: center; + padding: 12px; + font-size: 0.9em; + } +} +@media (max-width: 480px) { + .language-selector { + flex-wrap: wrap; + justify-content: flex-start; + gap: 8px; + } + .language-btn { + padding: 5px 8px; + font-size: 0.8em; + } +} +@media (max-width: 768px) { + .return-to-top { + bottom: 70px; + right: 20px; + width: 40px; + height: 40px; + } + body { + padding-bottom: 0; + } +} diff --git a/css/writing.css b/css/writing.css index 6211ec3..59bed27 100644 --- a/css/writing.css +++ b/css/writing.css @@ -203,11 +203,11 @@ textarea.answer-input { .tools-container { flex-direction: column; } - + .practice-area { flex-direction: column; } - + .character-display { margin-bottom: 10px; } @@ -218,12 +218,12 @@ textarea.answer-input { flex-wrap: wrap; justify-content: center; } - + .practice-cell { width: 50px; height: 50px; } - + .writing-input { width: 40px; height: 40px; diff --git a/day.html b/day.html index 73cd050..ae9176c 100644 --- a/day.html +++ b/day.html @@ -1,117 +1,186 @@ - + - - - - - - - - - Mandarin Pathways - Daily Lesson - - - - - - - - - -
-

Mandarin Pathways

-

开启您的中文学习之旅Your Journey to Chinese Language Mastery

-
+ + + + + + + + + Mandarin Pathways - Daily Lesson + + + + + + + + + +
+

Mandarin Pathways

+

+ 开启您的中文学习之旅Your Journey to Chinese Language Mastery +

+
-
- -
- -
+
+ +
+ +
- -
+ +
-
-
-
- -

Day

-
-
- - - +
+
+
+ +

+ Day +

+
+
+ + + +
-
- + - + -
- -
-
+
+ +
+
-
- -
+
+ +
-
- -
+
+ +
- -
+ +
- -
- -
-
+ +
+
- + -
- 短语已复制到剪贴板!Phrase copied to clipboard! -
+
+ 短语已复制到剪贴板!Phrase copied to clipboard! +
- - - - - - - + + + + + + + diff --git a/flutter_app/README.md b/flutter_app/README.md index ca67196..b5dce68 100644 --- a/flutter_app/README.md +++ b/flutter_app/README.md @@ -16,32 +16,38 @@ Mandarin Pathways has been converted from a Progressive Web App (PWA) to a nativ ## Features ### 📚 40-Day Structured Curriculum + - Daily lessons organized into 5 progressive sections - Lessons available in three formats: Simplified Chinese (简体中文), Pinyin, and English - Progress tracking across all languages ### 🎵 Audio Features + - Dual-language audio for every lesson (Chinese + English) - Variable playback speed (0.5x to 2.0x) - Loop functionality for practice - Seek controls with 10-second skip forward/backward ### ✍️ Core Skills Practice + - **Reading Skills**: Practice comprehension at multiple difficulty levels - **Writing Skills**: Character practice, sentence building, and translation exercises ### 📊 Progress Tracking + - Track completion across all 40 days - Separate progress for each language format - Visual progress indicators on home screen - Persistent storage using SharedPreferences ### 🔔 Notifications + - Daily reminder notifications - Customizable reminder times - Completion celebration notifications ### 🌐 Supplementary Materials + - Education & Academic Life - Hobbies & Interests - Emotions & Feelings @@ -99,65 +105,74 @@ flutter_app/ ### Installation 1. **Install Flutter**: - ```bash - # Clone Flutter repository - git clone https://github.com/flutter/flutter.git -b stable - # Add to PATH - export PATH="$PATH:`pwd`/flutter/bin" + ```bash + # Clone Flutter repository + git clone https://github.com/flutter/flutter.git -b stable - # Verify installation - flutter doctor - ``` + # Add to PATH + export PATH="$PATH:`pwd`/flutter/bin" + + # Verify installation + flutter doctor + ``` 2. **Install dependencies** (from the repository root, this folder is `flutter_app/`): - ```bash - cd flutter_app - flutter pub get - ``` + + ```bash + cd flutter_app + flutter pub get + ``` 3. **iOS / macOS native builds**: After dependency changes, refresh CocoaPods: - ```bash - cd ios && pod install && cd .. - cd macos && pod install && cd .. - ``` - Run these commands from **`flutter_app/`** (same directory as `pubspec.yaml`). + + ```bash + cd ios && pod install && cd .. + cd macos && pod install && cd .. + ``` + + Run these commands from **`flutter_app/`** (same directory as `pubspec.yaml`). 4. **Run the app**: - ```bash - # Run on connected device/emulator - flutter run - - # Run on specific platform - flutter run -d chrome # Web - flutter run -d ios # iOS - flutter run -d android # Android - flutter run -d macos # macOS - flutter run -d windows # Windows - flutter run -d linux # Linux - ``` + + ```bash + # Run on connected device/emulator + flutter run + + # Run on specific platform + flutter run -d chrome # Web + flutter run -d ios # iOS + flutter run -d android # Android + flutter run -d macos # macOS + flutter run -d windows # Windows + flutter run -d linux # Linux + ``` ### Building for Production #### Android (APK) + ```bash flutter build apk --release # Output: build/app/outputs/flutter-apk/app-release.apk ``` #### iOS (IPA) + ```bash flutter build ios --release # Then use Xcode to create IPA ``` #### Web + ```bash flutter build web --release # Output: build/web/ ``` #### Desktop + ```bash # macOS flutter build macos --release @@ -184,12 +199,14 @@ Hot-restart or rebuild after adding files so Flutter picks up new bundle entries ### Audio Files Audio files should be placed in `assets/audio/` with the naming convention: + - `day{n}_zh.mp3` - Mandarin audio - `day{n}_en.mp3` - English explanations ### Text Files Text content should be in `assets/text/` with the naming convention: + - `day{n}_zh.txt` - Simplified Chinese text - `day{n}_pinyin.txt` - Pinyin romanization - `day{n}_en.txt` - English translation @@ -207,20 +224,20 @@ Version pins and exact constraints are in **`pubspec.yaml`**; run `dart pub deps Packages referenced from current **`lib/`** code include: -| Package | Role | -|---------|------| -| `provider` | `AppState` + reactive UI wiring | -| `shared_preferences` | Progress, preferences, notification settings cache | -| `audioplayers` | Bundled lesson audio playback | -| `flutter_local_notifications` | Daily reminders / completion pings | -| `timezone` | Local time handling for notification scheduling | +| Package | Role | +| ----------------------------- | -------------------------------------------------- | +| `provider` | `AppState` + reactive UI wiring | +| `shared_preferences` | Progress, preferences, notification settings cache | +| `audioplayers` | Bundled lesson audio playback | +| `flutter_local_notifications` | Daily reminders / completion pings | +| `timezone` | Local time handling for notification scheduling | UI and typography: -| Package | Role | -|---------|------| -| `google_fonts` | Fonts such as Poppins | -| `font_awesome_flutter` | Icons | +| Package | Role | +| ---------------------- | --------------------- | +| `google_fonts` | Fonts such as Poppins | +| `font_awesome_flutter` | Icons | Still listed in **`pubspec.yaml`** (verify before removing—they may become unused during refactors): @@ -281,11 +298,13 @@ dart format lib/ ## Troubleshooting ### Audio Not Playing + - Ensure audio files are in `assets/audio/` directory - Verify `pubspec.yaml` includes audio assets - Run `flutter clean && flutter pub get` ### Build Errors + ```bash flutter clean flutter pub get @@ -325,6 +344,7 @@ Copyright © 2026 Mandarin Pathways. All rights reserved. ## Support For issues, questions, or suggestions: + - Open an issue on GitHub - Check the Flutter documentation: https://docs.flutter.dev - Review the original PWA at: [original web URL] diff --git a/flutter_app/analysis_options.yaml b/flutter_app/analysis_options.yaml index 0d29021..9a7b9b0 100644 --- a/flutter_app/analysis_options.yaml +++ b/flutter_app/analysis_options.yaml @@ -10,19 +10,19 @@ include: package:flutter_lints/flutter.yaml linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at https://dart.dev/lints. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. - rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index d36b1fa..f1e3e62 100644 --- a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,122 +1,122 @@ { - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" + "images": [ + { + "size": "20x20", + "idiom": "iphone", + "filename": "Icon-App-20x20@2x.png", + "scale": "2x" + }, + { + "size": "20x20", + "idiom": "iphone", + "filename": "Icon-App-20x20@3x.png", + "scale": "3x" + }, + { + "size": "29x29", + "idiom": "iphone", + "filename": "Icon-App-29x29@1x.png", + "scale": "1x" + }, + { + "size": "29x29", + "idiom": "iphone", + "filename": "Icon-App-29x29@2x.png", + "scale": "2x" + }, + { + "size": "29x29", + "idiom": "iphone", + "filename": "Icon-App-29x29@3x.png", + "scale": "3x" + }, + { + "size": "40x40", + "idiom": "iphone", + "filename": "Icon-App-40x40@2x.png", + "scale": "2x" + }, + { + "size": "40x40", + "idiom": "iphone", + "filename": "Icon-App-40x40@3x.png", + "scale": "3x" + }, + { + "size": "60x60", + "idiom": "iphone", + "filename": "Icon-App-60x60@2x.png", + "scale": "2x" + }, + { + "size": "60x60", + "idiom": "iphone", + "filename": "Icon-App-60x60@3x.png", + "scale": "3x" + }, + { + "size": "20x20", + "idiom": "ipad", + "filename": "Icon-App-20x20@1x.png", + "scale": "1x" + }, + { + "size": "20x20", + "idiom": "ipad", + "filename": "Icon-App-20x20@2x.png", + "scale": "2x" + }, + { + "size": "29x29", + "idiom": "ipad", + "filename": "Icon-App-29x29@1x.png", + "scale": "1x" + }, + { + "size": "29x29", + "idiom": "ipad", + "filename": "Icon-App-29x29@2x.png", + "scale": "2x" + }, + { + "size": "40x40", + "idiom": "ipad", + "filename": "Icon-App-40x40@1x.png", + "scale": "1x" + }, + { + "size": "40x40", + "idiom": "ipad", + "filename": "Icon-App-40x40@2x.png", + "scale": "2x" + }, + { + "size": "76x76", + "idiom": "ipad", + "filename": "Icon-App-76x76@1x.png", + "scale": "1x" + }, + { + "size": "76x76", + "idiom": "ipad", + "filename": "Icon-App-76x76@2x.png", + "scale": "2x" + }, + { + "size": "83.5x83.5", + "idiom": "ipad", + "filename": "Icon-App-83.5x83.5@2x.png", + "scale": "2x" + }, + { + "size": "1024x1024", + "idiom": "ios-marketing", + "filename": "Icon-App-1024x1024@1x.png", + "scale": "1x" + } + ], + "info": { + "version": 1, + "author": "xcode" } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } } diff --git a/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json index 0bedcf2..dcd5980 100644 --- a/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json +++ b/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -1,23 +1,23 @@ { - "images" : [ - { - "idiom" : "universal", - "filename" : "LaunchImage.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@3x.png", - "scale" : "3x" + "images": [ + { + "idiom": "universal", + "filename": "LaunchImage.png", + "scale": "1x" + }, + { + "idiom": "universal", + "filename": "LaunchImage@2x.png", + "scale": "2x" + }, + { + "idiom": "universal", + "filename": "LaunchImage@3x.png", + "scale": "3x" + } + ], + "info": { + "version": 1, + "author": "xcode" } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } } diff --git a/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md index 89c2725..b5b843a 100644 --- a/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md +++ b/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -2,4 +2,4 @@ You can customize the launch screen with your own desired assets by replacing the image files in this directory. -You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. diff --git a/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index a2ec33f..6e00ef8 100644 --- a/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,68 +1,68 @@ { - "images" : [ - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_16.png", - "scale" : "1x" - }, - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "2x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "1x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_64.png", - "scale" : "2x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_128.png", - "scale" : "1x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "2x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "1x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "2x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "1x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_1024.png", - "scale" : "2x" + "images": [ + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_16.png", + "scale": "1x" + }, + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "2x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "1x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_64.png", + "scale": "2x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_128.png", + "scale": "1x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "2x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "1x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "2x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "1x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_1024.png", + "scale": "2x" + } + ], + "info": { + "version": 1, + "author": "xcode" } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } } diff --git a/flutter_app/pubspec.yaml b/flutter_app/pubspec.yaml index 8ed62bf..c96b18b 100644 --- a/flutter_app/pubspec.yaml +++ b/flutter_app/pubspec.yaml @@ -3,46 +3,46 @@ description: Learn Mandarin Chinese with structured daily lessons and supplement version: 1.0.0+1 environment: - sdk: '>=3.10.0 <4.0.0' + sdk: ">=3.10.0 <4.0.0" dependencies: - flutter: - sdk: flutter + flutter: + sdk: flutter - # UI & Design - cupertino_icons: ^1.0.6 - google_fonts: ^8.1.0 - font_awesome_flutter: ^11.0.0 + # UI & Design + cupertino_icons: ^1.0.6 + google_fonts: ^8.1.0 + font_awesome_flutter: ^11.0.0 - # State Management - provider: ^6.1.1 + # State Management + provider: ^6.1.1 - # Local Storage - shared_preferences: ^2.2.2 - path_provider: ^2.1.1 + # Local Storage + shared_preferences: ^2.2.2 + path_provider: ^2.1.1 - # Audio Playback - audioplayers: ^6.6.0 + # Audio Playback + audioplayers: ^6.6.0 - # Networking - http: ^1.1.2 + # Networking + http: ^1.1.2 - # Notifications - flutter_local_notifications: ^21.0.0 - timezone: ^0.11.0 + # Notifications + flutter_local_notifications: ^21.0.0 + timezone: ^0.11.0 - # Utilities - url_launcher: ^6.2.2 + # Utilities + url_launcher: ^6.2.2 dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 flutter: - uses-material-design: true + uses-material-design: true - assets: - - assets/audio/ - - assets/icons/ - - assets/text/ + assets: + - assets/audio/ + - assets/icons/ + - assets/text/ diff --git a/flutter_app/web/index.html b/flutter_app/web/index.html index fe0aaed..331958a 100644 --- a/flutter_app/web/index.html +++ b/flutter_app/web/index.html @@ -1,7 +1,7 @@ - + - - - + - - - + + + - - - - - + + + + + - - + + - mandarin_pathways - - - - - + mandarin_pathways + + + + + diff --git a/index.html b/index.html index f78384e..352587e 100644 --- a/index.html +++ b/index.html @@ -1,496 +1,1092 @@ - + - - - - - - - - - Mandarin Pathways - Your Journey to Chinese Language Mastery - - - - - - - - - - -
-
- - - -
-

Mandarin Pathways

-

开启您的中文学习之旅Your Journey to Chinese Language Mastery

- -
+ + + + + + + + + + Mandarin Pathways - Your Journey to Chinese Language Mastery + + + + + + + + + + + +
+
+ + + +
+

Mandarin Pathways

+

+ 开启您的中文学习之旅Your Journey to Chinese Language Mastery +

+ +
-
-
-

掌握中文Master Mandarin Chinese

-

通过40天全面的中文学习,开启全球机遇Embark on a 40-day journey through comprehensive Mandarin learning for global opportunities

-
-
+
+
+

+ 掌握中文Master Mandarin Chinese +

+

+ 通过40天全面的中文学习,开启全球机遇Embark on a 40-day journey through comprehensive + Mandarin learning for global opportunities +

+
+
-
- -
- -
-
-
-
-

- 🇨🇳 - 简体中文 -

-
-
-
-

进度:第0/40天Progress: Day 0/40

-
-
-
+
+ +
+ +
+
+
+
+

+ 🇨🇳 + 简体中文 +

-
-
-
40
-
Days
+
+
+

+ 进度:第0/40天Progress: Day 0/40 +

+
+
+
-
-
5
-
级别Levels
+
+
+
40
+
+ Days +
+
+
+
5
+
+ 级别Levels +
+
-
-
-
-

- 🔤 - Pinyin -

-
-
-
-

进度:第0/40天Progress: Day 0/40

-
-
-
+
+
+

+ 🔤 + Pinyin +

-
-
-
40
-
Days
+
+
+

+ 进度:第0/40天Progress: Day 0/40 +

+
+
+
-
-
5
-
级别Levels
+
+
+
40
+
+ Days +
+
+
+
5
+
+ 级别Levels +
+
-
-
-
-

- 🇺🇸 - English -

-
-
-
-

进度:第0/40天Progress: Day 0/40

-
-
-
+
+
+

+ 🇺🇸 + English +

-
-
-
40
-
Days
+
+
+

+ 进度:第0/40天Progress: Day 0/40 +

+
+
+
-
-
5
-
级别Levels
+
+
+
40
+
+ Days +
+
+
+
5
+
+ 级别Levels +
+
-
- -
+ +
- -
+ +
-
-

课程结构Course Structure

-
-
-

第1-7天Days 1-7

-

拼音系统、声调和基本发音Pinyin system, tones, and pronunciation basics

-
-
-

第8-14天Days 8-14

-

基本日常用语和基础语法Essential daily phrases and basic grammar

-
-
-

第15-22天Days 15-22

-

文化背景和日常生活交流Cultural context and daily life communication

-
-
-

第23-30天Days 23-30

-

职业和商务中文Professional and business Mandarin

-
-
-

第31-40天Days 31-40

-

高级流利度和实际应用Advanced fluency and real-world applications

+
+

+ 课程结构Course Structure +

+
+
+

+ + 第1-7天Days 1-7 +

+

+ 拼音系统、声调和基本发音Pinyin system, tones, and pronunciation + basics +

+
+
+

+ + 第8-14天Days 8-14 +

+

+ 基本日常用语和基础语法Essential daily phrases and basic grammar +

+
+
+

+ + 第15-22天Days 15-22 +

+

+ 文化背景和日常生活交流Cultural context and daily life + communication +

+
+
+

+ + 第23-30天Days 23-30 +

+

+ 职业和商务中文Professional and business Mandarin +

+
+
+

+ + 第31-40天Days 31-40 +

+

+ 高级流利度和实际应用Advanced fluency and real-world + applications +

+
-
-
- - -
+ -
-

每日课程Daily Lessons

+ +
- -
- -
Days 1-10
- -
+
+

+ 每日课程Daily Lessons +

-
- -
-
- - -
- - -
-

核心语言技能Core Language Skills

-

通过这些专项练习增强您的日常学习,提高阅读和写作能力。Enhance your daily learning with focused practice on these essential language skills.

- -
+ +
+ +
+ Days 1-10 +
+ +
- -
+
+ +
+
- -
-

补充学习材料Supplementary Learning Materials

- -
+ +
- -
+ +
+

+ 核心语言技能Core Language Skills +

+

+ 通过这些专项练习增强您的日常学习,提高阅读和写作能力。Enhance your daily learning with focused practice on + these essential language skills. +

+ +
- -
+ +
-
- -
-

为什么学习中文?Why Learn Mandarin Chinese?

-
-
-

全球影响力Global Reach & Impact

-
    -
  • 母语使用者:Native Speakers: 全球超过10亿Over 1 billion worldwide
  • -
  • 商业:Business: 中国巨大的经济影响力China's massive economic influence
  • -
  • 科技:Technology: 主要科技创新中心Major tech innovation hub
  • -
  • 文化:Culture: 五千年丰富文化遗产Rich 5000-year cultural heritage
  • -
  • 未来:Future: 日益增长的全球重要性Growing global significance
  • -
-
-
-

职业优势Career Advantages

-
    -
  • 国际商务机会International business opportunities
  • -
  • 科技领域合作Tech sector collaboration
  • -
  • 进出口优势Import/export advantages
  • -
  • 外交和政府职位Diplomatic and government positions
  • -
  • 跨文化咨询Cross-cultural consulting
  • -
-
-
-

商业潜力Business Potential

-
    -
  • 进入中国市场Access to Chinese market
  • -
  • 电子商务机会E-commerce opportunities
  • -
  • 翻译服务Translation services
  • -
  • 语言教学Language teaching
  • -
  • 文化咨询Cultural consulting
  • -
-
-
-

认知优势Cognitive Benefits

-
    -
  • 通过汉字学习增强记忆力Enhanced memory through character learning
  • -
  • 提高模式识别能力Improved pattern recognition
  • -
  • 更好的多任务处理能力Better multitasking abilities
  • -
  • 更强的问题解决能力Stronger problem-solving skills
  • -
-
-
-

文化接触Cultural Access

-
    -
  • 理解中国哲学Understand Chinese philosophy
  • -
  • 欣赏中国艺术Appreciate Chinese arts
  • -
  • 与当地社区联系Connect with local communities
  • -
  • 自信地探索Navigate with confidence
  • -
-
-
-
+ +
-
-

© 2026 Mandarin Pathways | 赋能语言学习Empowering Language Learning

-
+ +
+
+ +
+

+ 为什么学习中文?Why Learn Mandarin Chinese? +

+
+
+

+ + 全球影响力Global Reach & Impact +

+
    +
  • + 母语使用者:Native Speakers: + 全球超过10亿Over 1 billion worldwide +
  • +
  • + 商业:Business: + 中国巨大的经济影响力China's massive economic influence +
  • +
  • + 科技:Technology: + 主要科技创新中心Major tech innovation hub +
  • +
  • + 文化:Culture: + 五千年丰富文化遗产Rich 5000-year cultural heritage +
  • +
  • + 未来:Future: + 日益增长的全球重要性Growing global significance +
  • +
+
+
+

+ + 职业优势Career Advantages +

+
    +
  • + 国际商务机会International business opportunities +
  • +
  • + 科技领域合作Tech sector collaboration +
  • +
  • + 进出口优势Import/export advantages +
  • +
  • + 外交和政府职位Diplomatic and government positions +
  • +
  • + 跨文化咨询Cross-cultural consulting +
  • +
+
+
+

+ + 商业潜力Business Potential +

+
    +
  • + 进入中国市场Access to Chinese market +
  • +
  • + 电子商务机会E-commerce opportunities +
  • +
  • + 翻译服务Translation services +
  • +
  • + 语言教学Language teaching +
  • +
  • + 文化咨询Cultural consulting +
  • +
+
+
+

+ + 认知优势Cognitive Benefits +

+
    +
  • + 通过汉字学习增强记忆力Enhanced memory through character + learning +
  • +
  • + 提高模式识别能力Improved pattern recognition +
  • +
  • + 更好的多任务处理能力Better multitasking abilities +
  • +
  • + 更强的问题解决能力Stronger problem-solving skills +
  • +
+
+
+

+ + 文化接触Cultural Access +

+
    +
  • + 理解中国哲学Understand Chinese philosophy +
  • +
  • + 欣赏中国艺术Appreciate Chinese arts +
  • +
  • + 与当地社区联系Connect with local communities +
  • +
  • + 自信地探索Navigate with confidence +
  • +
+
+
+

+ + 书写系统Writing Systems +

+
+
+ 简体中文Simplified Chinese + 中国大陆标准Mainland China standard +
+
+ 繁体中文Traditional Chinese + 台湾和香港使用Taiwan & Hong Kong usage +
+
+ 拼音系统Pinyin System + 语音学习辅助Phonetic learning aid +
+
+ 汉字书写Character Writing + 文化欣赏Cultural appreciation +
+
+
+
+
+
- - + - - diff --git a/srs.html b/srs.html new file mode 100644 index 0000000..f96e8d9 --- /dev/null +++ b/srs.html @@ -0,0 +1,84 @@ + + + + + + + + + + Mandarin Pathways - SRS Review + + + + + + + + + +
+

Mandarin Pathways

+

+ 间隔重复复习Spaced repetition review +

+
+ +
+
+ + +
+
+
+ + + + + + + + + + diff --git a/sw.js b/sw.js index ce52b26..ba06047 100644 --- a/sw.js +++ b/sw.js @@ -1,4 +1,4 @@ -const CACHE_VERSION = "10"; +const CACHE_VERSION = "11"; const CACHE_NAME = `mandarin-pathways-v${CACHE_VERSION}`; // Cache groups for different types of resources @@ -14,6 +14,9 @@ const STATIC_ASSETS = [ "writing.html", "supplementary.html", "review.html", + "srs.html", + "placement.html", + "quiz.html", "manifest.json", "css/styles.min.css", "css/reading.css", @@ -30,6 +33,12 @@ const STATIC_ASSETS = [ "js/supplementary-page.js", "js/data-portability.js", "js/review-page.js", + "js/streaks.js", + "js/srs.js", + "js/placement-page.js", + "js/quiz.js", + "js/tone-visualizer.js", + "js/hanzi-writer-lite.js", "js/character-drawing.js", "js/notifications.js", "icons/icon-72x72.png", diff --git a/tests/e2e/learning-depth.spec.ts b/tests/e2e/learning-depth.spec.ts new file mode 100644 index 0000000..02eec87 --- /dev/null +++ b/tests/e2e/learning-depth.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Phase 3 learning depth support", () => { + test("tone visualizer and writer globals render offline widgets", async ({ + page, + }) => { + await page.goto("/"); + await page.setContent(` +
+
+
+
+ `); + await page.addScriptTag({ url: "/js/tone-visualizer.js" }); + await page.addScriptTag({ url: "/js/hanzi-writer-lite.js" }); + + const result = await page.evaluate(async () => { + window.MandarinTones.renderInto("#tones", "ma1 ma2 ma3 ma4 ma5"); + const writer = window.HanziWriterLite.create("writer", "你", { + width: 120, + height: 120, + delayBetweenStrokes: 1, + }); + const data = await window.HanziWriterLite.loadCharacterData("你"); + await writer.animateCharacter(); + + return { + tones: Array.from( + document.querySelectorAll("#tones .tone-glyph"), + ).map((el) => el.getAttribute("data-tone")), + writerCharacter: document + .querySelector("#writer svg") + ?.getAttribute("data-character"), + strokeCount: document.querySelectorAll( + "#writer .hanzi-writer-lite-stroke", + ).length, + dataSource: data.source, + }; + }); + + expect(result.tones).toEqual(["1", "2", "3", "4", "5"]); + expect(result.writerCharacter).toBe("你"); + expect(result.strokeCount).toBeGreaterThan(1); + expect(result.dataSource).toBe("bundled"); + }); +}); diff --git a/tests/e2e/retention.spec.ts b/tests/e2e/retention.spec.ts new file mode 100644 index 0000000..0130c6a --- /dev/null +++ b/tests/e2e/retention.spec.ts @@ -0,0 +1,70 @@ +import { test, expect } from "@playwright/test"; + +test.describe("retention loop", () => { + test("SRS review writes card state after grading", async ({ page }) => { + await page.goto("/srs.html"); + await page.evaluate(() => { + localStorage.setItem( + "srsCards", + JSON.stringify([ + { + id: "test-card", + day: 1, + lang: "zh", + front: "你好", + back: "Hello", + due: new Date(Date.now() - 1000).toISOString(), + interval: 0, + ease: 2.5, + reps: 0, + }, + ]), + ); + }); + await page.reload(); + + await expect(page.locator(".srs-front")).toContainText("你好"); + await page.getByRole("button", { name: "Show answer" }).click(); + await page.getByRole("button", { name: "Good" }).click(); + + const stored = await page.evaluate(() => + JSON.parse(localStorage.getItem("srsCards") || "[]"), + ); + expect(stored[0].reps).toBe(1); + }); + + test("lesson completion records a learning streak", async ({ page }) => { + await page.goto("/day.html?day=1&lang=zh", { waitUntil: "load" }); + await page.locator("#complete-btn").click(); + + await expect + .poll(async () => + page.evaluate(() => localStorage.getItem("streakCount")), + ) + .toBe("1"); + }); + + test("home dashboard surfaces SRS and streak state", async ({ page }) => { + await page.goto("/"); + await page.evaluate(() => { + localStorage.setItem("streakCount", "3"); + localStorage.setItem( + "srsCards", + JSON.stringify([ + { + id: "due", + front: "谢谢", + back: "Thanks", + due: new Date(Date.now() - 1000).toISOString(), + }, + ]), + ); + }); + await page.reload(); + + await expect(page.locator("[data-streak-count]").first()).toHaveText( + "3", + ); + await expect(page.locator("#srs-due-count")).toHaveText("1"); + }); +}); diff --git a/tests/e2e/self-assessment.spec.ts b/tests/e2e/self-assessment.spec.ts new file mode 100644 index 0000000..8eaca7b --- /dev/null +++ b/tests/e2e/self-assessment.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Self assessment", () => { + test("placement test stores a recommendation", async ({ page }) => { + await page.goto("/placement.html"); + + await expect( + page.getByRole("heading", { name: /Find your starting point/i }), + ).toBeVisible(); + + const answers = [ + "Hello", + "xie xie", + "I would like a glass of water", + "这个多少钱?", + "I want to watch a movie this weekend", + "Menu", + "我学中文学了两年。", + "Cause and result", + "Environmental protection", + "Reduce waste", + ]; + + for (const answer of answers) { + await page.getByLabel(answer).check(); + } + + await page.getByRole("button", { name: /Score placement/i }).click(); + await expect(page.getByTestId("placement-result")).toContainText( + "Recommended start: Day 31", + ); + + const stored = await page.evaluate(() => + JSON.parse(localStorage.getItem("placementResult") || "{}"), + ); + expect(stored.score).toBe(10); + expect(stored.recommendedDay).toBe(31); + }); + + test("day quiz stores the best score", async ({ page }) => { + await page.goto("/quiz.html?day=1"); + + await expect( + page.getByRole("heading", { name: /Quiz by day/i }), + ).toBeVisible(); + await page.getByLabel("How are you?").check(); + await page.getByLabel("zai jian").check(); + await page.getByTestId("quiz-fill-3").fill("我"); + + await page.getByRole("button", { name: /Score quiz/i }).click(); + await expect(page.getByTestId("quiz-result")).toContainText( + "Day 1 score: 3/3", + ); + + const stored = await page.evaluate(() => + JSON.parse(localStorage.getItem("quizScores") || "{}"), + ); + expect(stored.day1.bestScore).toBe(3); + expect(stored.day1.total).toBe(3); + }); +}); diff --git a/writing.html b/writing.html index 3ff6611..880356d 100644 --- a/writing.html +++ b/writing.html @@ -32,6 +32,7 @@ /> + From 7f7ba045193e6556e7b4ba185f51f28ea8002817 Mon Sep 17 00:00:00 2001 From: Donnivis Baker Date: Tue, 9 Jun 2026 13:02:31 -0400 Subject: [PATCH 3/5] Add i18n helpers, lang switcher & localizations Introduce shared i18n utilities and standardize language toggling across the app. Adds js/i18n.js and js/lang-switcher.js, updates sw.js precache, and wires i18n into page scripts (e.g. day.html). Localized dynamic UI strings and controls across many pages and scripts (quiz, placement, reading, writing, supplementary, review, srs, character-drawing, etc.), and added CSS for modals and quiz day selector (styles.css + styles.min.css cleanup). Also includes an implementation plan doc (docs/plans/...), an end-to-end localization test, and other small HTML tweaks (font preload, noscript fallbacks) to reduce English-only flashes and ensure consistent language behavior. --- css/styles.css | 91 ++++++++ css/styles.min.css | 331 ++++++++---------------------- day.html | 15 ++ index.html | 79 ++----- js/character-drawing.js | 18 +- js/day-page.js | 84 ++++++-- js/i18n.js | 70 +++++++ js/lang-switcher.js | 23 +++ js/placement-page.js | 90 ++++++-- js/quiz.js | 56 ++++- js/reading-page.js | 101 +++++---- js/review-page.js | 7 +- js/script.js | 4 +- js/script.min.js | 4 +- js/srs.js | 78 ++++++- js/streaks.js | 66 ++++-- js/supplementary-page.js | 58 +++--- js/writing-page.js | 137 ++++--------- placement.html | 61 +++++- quiz.html | 66 +++++- reading.html | 2 + review.html | 39 ++++ srs.html | 56 ++++- supplementary.html | 2 + sw.js | 4 +- tests/e2e/localization.spec.ts | 161 +++++++++++++++ tests/e2e/retention.spec.ts | 37 +++- tests/e2e/self-assessment.spec.ts | 20 +- tests/e2e/smoke.spec.ts | 29 +++ writing.html | 16 ++ 30 files changed, 1229 insertions(+), 576 deletions(-) create mode 100644 js/i18n.js create mode 100644 js/lang-switcher.js create mode 100644 tests/e2e/localization.spec.ts diff --git a/css/styles.css b/css/styles.css index fb876b1..3cfb6e7 100644 --- a/css/styles.css +++ b/css/styles.css @@ -1682,3 +1682,94 @@ footer { padding-bottom: 0; /* Remove padding since footer is no longer fixed */ } } + +/* Modals */ +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modal-content { + background: white; + padding: 2rem; + border-radius: 8px; + max-width: 400px; + width: 90%; +} + +.settings-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.button-group { + display: flex; + gap: 1rem; + justify-content: flex-end; + margin-top: 1rem; +} + +.primary-btn { + background: var(--primary-color); + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; + font-family: inherit; + font-size: inherit; +} + +/* Quiz day selector */ +.level-selector { + margin: 1.5rem 0; +} + +.level-selector h3 { + font-size: 1rem; + margin-bottom: 0.75rem; + color: var(--text-color); +} + +.level-buttons { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.level-btn { + padding: 0.4rem 0.9rem; + border-radius: 20px; + border: 2px solid var(--accent-color); + background: transparent; + color: var(--accent-color); + text-decoration: none; + font-size: 0.875rem; + font-family: inherit; + transition: + background 0.2s, + color 0.2s; + display: inline-block; + cursor: pointer; +} + +.level-btn.active, +.level-btn:hover { + background: var(--accent-color); + color: white; +} diff --git a/css/styles.min.css b/css/styles.min.css index fb876b1..692705d 100644 --- a/css/styles.min.css +++ b/css/styles.min.css @@ -1,4 +1,3 @@ -/* Preload fonts to prevent layout shifts */ @font-face { font-family: "Noto Sans SC"; font-style: normal; @@ -11,7 +10,6 @@ format("woff2"); unicode-range: U+4E00-9FFF; } - :root { --primary-color: #1a2733; --secondary-color: #f8f9fa; @@ -22,7 +20,6 @@ --warning-color: #996600; --danger-color: #c0392b; } - body { font-family: "Poppins", "Noto Sans SC", Arial, sans-serif; margin: 0; @@ -32,7 +29,6 @@ body { line-height: 1.6; padding-bottom: 60px; } - header { background: linear-gradient(135deg, var(--primary-color), #34495e); color: white; @@ -40,20 +36,17 @@ header { text-align: center; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); } - header h1 { margin: 0; font-size: 2.5em; font-weight: 700; letter-spacing: 1px; } - header p { margin: 10px 0 0; font-size: 1.2em; opacity: 0.9; } - .hero-section { background: url("https://images.unsplash.com/photo-1546410531-89436e5b419a?auto=format&fit=crop&w=1920&q=80") center/cover; @@ -62,7 +55,6 @@ header p { color: white; position: relative; } - .hero-section::before { content: ""; position: absolute; @@ -70,26 +62,18 @@ header p { left: 0; right: 0; bottom: 0; - background: rgba( - 44, - 62, - 80, - 0.7 - ); /* Reduced opacity for better visibility */ -} - + background: rgba(44, 62, 80, 0.7); +} .hero-content { position: relative; max-width: 800px; margin: 0 auto; } - .hero-content h2 { font-size: 2.8em; margin-bottom: 20px; font-weight: 700; } - .hero-content p { font-size: 1.2em; margin-bottom: 30px; @@ -98,10 +82,8 @@ main { padding: 20px; max-width: 1200px; margin: 0 auto; - position: relative; /* For positioning the return to top button */ + position: relative; } - -/* Visual anchor pattern - subtle dots pattern that repeats throughout sections */ .visual-anchor { position: absolute; width: 200px; @@ -116,7 +98,6 @@ main { z-index: -1; border-radius: 50%; } - .language-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); @@ -125,7 +106,6 @@ main { max-width: 1200px; margin: 0 auto; } - .language-card { background: white; border-radius: 15px; @@ -137,12 +117,10 @@ main { cursor: pointer; position: relative; } - .language-card:hover { transform: translateY(-10px); box-shadow: 0 15px 30px rgba(0, 0, 0, 0.15); } - .language-card::after { content: attr(title); position: absolute; @@ -162,12 +140,10 @@ main { white-space: nowrap; z-index: 10; } - .language-card:hover::after { opacity: 1; top: -50px; } - .card-header { padding: 20px; background: linear-gradient( @@ -177,7 +153,6 @@ main { ); color: white; } - .card-header h3 { margin: 0; font-size: 1.5em; @@ -185,82 +160,68 @@ main { align-items: center; gap: 10px; } - .flag { font-size: 1.2em; } - .card-content { padding: 20px; } - .progress-container { margin: 15px 0; } - .progress-bar { height: 8px; background: #ecf0f1; border-radius: 4px; overflow: hidden; } - .progress-fill { height: 100%; background: var(--success-color); width: 0%; transition: width 1s ease; } - .stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 15px; margin-top: 20px; } - .learning-dashboard { padding: 0 20px 30px; } - .dashboard-panel { background: white; border: 1px solid #e6e9ec; border-radius: 8px; padding: 24px; } - .dashboard-panel h2 { margin-top: 0; } - .dashboard-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin: 18px 0; } - .dashboard-stat { border: 1px solid #e6e9ec; border-radius: 8px; padding: 16px; background: #fbfcfd; } - .dashboard-number { display: block; color: var(--accent-color); font-size: 2rem; font-weight: 700; } - .dashboard-label { display: block; font-size: 0.9rem; color: #4d5b66; } - .achievement-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); @@ -269,7 +230,6 @@ main { padding: 0; margin: 18px 0 0; } - .achievement-badge { border: 1px solid #dce2e7; border-radius: 8px; @@ -277,41 +237,33 @@ main { background: #f5f7f8; color: #697782; } - .achievement-badge.earned { border-color: rgba(30, 132, 73, 0.35); background: rgba(30, 132, 73, 0.08); color: var(--text-color); } - .achievement-badge strong, .achievement-badge span { display: block; } - .achievement-badge span { font-size: 0.85rem; } - .stat-item { text-align: center; padding: 10px; background: var(--secondary-color); border-radius: 8px; } - .stat-number { font-size: 1.5em; font-weight: 700; color: var(--accent-color); } - .stat-label { font-size: 0.9em; color: #444; } - -/* Section dividers */ .section-divider { height: 3px; background: linear-gradient( @@ -326,7 +278,6 @@ main { opacity: 0.5; position: relative; } - .section-divider::before { content: ""; position: absolute; @@ -340,27 +291,23 @@ main { transform: translate(-50%, -50%); opacity: 0.8; } - .course-overview { padding: 40px 0; position: relative; overflow: hidden; } - .course-overview h2 { text-align: center; margin-bottom: 30px; color: var(--primary-color); font-size: 2em; } - .course-sections { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 20px; padding: 0 20px; } - .section-card { background: white; padding: 25px; @@ -371,12 +318,10 @@ main { box-shadow 0.3s ease; border-left: 4px solid var(--accent-color); } - .section-card:hover { transform: translateY(-5px); box-shadow: 0 8px 15px rgba(0, 0, 0, 0.15); } - .section-card h3 { color: var(--accent-color); margin: 0 0 15px 0; @@ -385,40 +330,33 @@ main { align-items: center; gap: 10px; } - .section-card h3 i { font-size: 1.2em; opacity: 0.9; } - .section-card p { margin: 0; color: #444; font-size: 0.95em; line-height: 1.5; } - #day-selection { margin: 40px 0; position: relative; overflow: hidden; } - #day-selection h2 { text-align: center; margin-bottom: 30px; color: var(--primary-color); font-size: 2em; } - -/* Day grid pagination controls */ .day-controls { display: flex; justify-content: center; margin-bottom: 20px; gap: 10px; } - .day-controls button { background: var(--accent-color); color: white; @@ -432,12 +370,10 @@ main { align-items: center; gap: 5px; } - .day-controls button:hover { background: var(--hover-color); transform: translateY(-2px); } - .day-controls .day-range { display: flex; align-items: center; @@ -448,14 +384,12 @@ main { color: var(--primary-color); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } - .day-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 15px; padding: 20px; } - .day-grid a { display: flex; flex-direction: column; @@ -470,14 +404,12 @@ main { transition: all 0.3s ease; position: relative; } - .day-grid a:hover { transform: translateY(-5px); box-shadow: 0 8px 15px rgba(0, 0, 0, 0.15); background: var(--accent-color); color: white; } - .day-grid a:hover::after { content: attr(title); position: absolute; @@ -492,19 +424,15 @@ main { white-space: nowrap; z-index: 10; } - .day-number { font-size: 1.5em; font-weight: 700; margin-bottom: 5px; } - .day-status { font-size: 0.8em; color: #444; } - -/* Return to top button */ .return-to-top { position: fixed; bottom: 80px; @@ -524,17 +452,14 @@ main { transition: all 0.3s ease; z-index: 99; } - .return-to-top.visible { opacity: 1; visibility: visible; } - .return-to-top:hover { background: var(--accent-color); transform: translateY(-5px); } - footer { text-align: center; padding: 1.5em 0; @@ -542,63 +467,50 @@ footer { color: white; width: 100%; z-index: 100; - position: relative; /* Changed from fixed to relative */ + position: relative; } - @media (max-width: 768px) { .hero-section { padding: 60px 20px; } - .hero-content h2 { font-size: 2em; } - .language-cards { grid-template-columns: 1fr; padding: 20px; } - .course-sections { grid-template-columns: 1fr; } - .day-grid { grid-template-columns: repeat(auto-fit, minmax(80px, 1fr)); gap: 10px; } - .day-grid a { padding: 15px 10px; } - .day-number { font-size: 1.2em; } - .section-card { padding: 20px; } - .section-card h3 { font-size: 1.2em; } } - -/* Core Skills Section Styles */ .core-skills-section { padding: 40px 0; position: relative; overflow: hidden; } - .core-skills-section h2 { text-align: center; margin-bottom: 15px; color: var(--primary-color); font-size: 2em; } - .section-description { text-align: center; max-width: 800px; @@ -607,14 +519,12 @@ footer { font-size: 1.1em; line-height: 1.5; } - .core-skills-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); gap: 30px; padding: 0 20px; } - .core-skill-card { background: white; padding: 30px; @@ -631,12 +541,10 @@ footer { display: flex; flex-direction: column; } - .core-skill-card:hover { transform: translateY(-10px); box-shadow: 0 12px 24px rgba(0, 0, 0, 0.2); } - .core-skill-card .card-icon { width: 70px; height: 70px; @@ -651,7 +559,6 @@ footer { position: relative; z-index: 1; } - .core-skill-card h3 { color: var(--primary-color); margin: 0 0 15px 0; @@ -659,7 +566,6 @@ footer { position: relative; z-index: 1; } - .core-skill-card p { margin: 0; color: #555; @@ -668,7 +574,6 @@ footer { position: relative; z-index: 1; } - .core-skill-card::before { content: ""; position: absolute; @@ -686,48 +591,39 @@ footer { right: -125px; top: -125px; } - @media (max-width: 768px) { .core-skills-grid { grid-template-columns: 1fr; } - .core-skill-card { padding: 25px; } - .core-skill-card .card-icon { width: 60px; height: 60px; font-size: 1.5em; } - .core-skill-card h3 { font-size: 1.3em; } } - -/* Supplementary Section Styles */ .supplementary-section { padding: 40px 0; position: relative; overflow: hidden; } - .supplementary-section h2 { text-align: center; margin-bottom: 30px; color: var(--primary-color); font-size: 2em; } - .supplementary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 25px; padding: 0 20px; } - .supplementary-card { background: white; padding: 25px; @@ -738,29 +634,21 @@ footer { position: relative; overflow: hidden; } - -/* Language-specific text display */ [lang="en"] .zh { display: none; } - [lang="zh-CN"] .en { display: none; } - -/* Chinese text styling */ .zh { font-family: "Noto Sans SC", sans-serif; } - -/* Language selector styles */ .language-selector { display: flex; justify-content: center; gap: 10px; margin-bottom: 20px; } - .language-btn { padding: 8px 15px; border: none; @@ -774,7 +662,6 @@ footer { gap: 5px; font-size: 0.9em; } - .language-btn:hover, .language-btn.active { background: rgba(255, 255, 255, 0.3); @@ -782,20 +669,16 @@ footer { box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); font-weight: 600; } - -/* Media queries for language selector */ @media (max-width: 768px) { .language-selector { flex-wrap: wrap; gap: 5px; } - .language-btn { padding: 6px 10px; font-size: 0.85em; } } - .supplementary-card::before { content: ""; position: absolute; @@ -813,12 +696,10 @@ footer { right: -100px; top: -100px; } - .supplementary-card:hover { transform: translateY(-10px); box-shadow: 0 8px 15px rgba(0, 0, 0, 0.15); } - .card-icon { width: 60px; height: 60px; @@ -833,7 +714,6 @@ footer { position: relative; z-index: 1; } - .supplementary-card h3 { color: var(--accent-color); margin: 0 0 10px 0; @@ -841,7 +721,6 @@ footer { position: relative; z-index: 1; } - .supplementary-card p { margin: 0; color: #444; @@ -850,16 +729,12 @@ footer { position: relative; z-index: 1; } - -/* Benefits Section Styles */ .benefits-section { padding: 40px 0; background: var(--secondary-color); position: relative; overflow: hidden; } - -/* Visual storytelling journey path */ .journey-path { position: absolute; top: 0; @@ -875,7 +750,6 @@ footer { opacity: 0.2; z-index: 0; } - .journey-path::before, .journey-path::after { content: ""; @@ -888,29 +762,24 @@ footer { transform: translateX(-50%); opacity: 0.4; } - .journey-path::before { top: 10%; } - .journey-path::after { bottom: 10%; } - .benefits-section h2 { text-align: center; margin-bottom: 30px; color: var(--primary-color); font-size: 2em; } - .benefits-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 25px; padding: 0 20px; } - .benefit-card { background: white; padding: 25px; @@ -918,12 +787,10 @@ footer { box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); transition: transform 0.3s ease; } - .benefit-card:hover { transform: translateY(-5px); box-shadow: 0 8px 15px rgba(0, 0, 0, 0.15); } - .benefit-card h3 { color: var(--accent-color); margin: 0 0 20px 0; @@ -932,95 +799,76 @@ footer { align-items: center; gap: 10px; } - .benefit-card h3 i { font-size: 1.2em; opacity: 0.9; } - .benefit-card ul { list-style: none; padding: 0; margin: 0; } - .benefit-card ul li { margin-bottom: 12px; padding-left: 20px; position: relative; } - .benefit-card ul li:before { content: "•"; color: var(--accent-color); position: absolute; left: 0; } - .combo-table { margin-top: 15px; } - .combo-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #eee; } - .combo-row:last-child { border-bottom: none; } - .combo { font-weight: 600; color: var(--primary-color); } - .benefit { color: var(--accent-color); text-align: right; } - @media (max-width: 768px) { .benefits-grid { grid-template-columns: 1fr; } - .combo-row { flex-direction: column; align-items: flex-start; gap: 5px; } - .benefit { text-align: left; } } - @media (max-width: 480px) { header h1 { font-size: 2em; } - .hero-content h2 { font-size: 1.8em; } - .hero-content p { font-size: 1em; } - body { padding-bottom: 80px; } - .language-card::after { display: none; } } - -/* Animations */ @keyframes fadeIn { from { opacity: 0; @@ -1031,7 +879,6 @@ footer { transform: translateY(0); } } - @keyframes pulse { 0% { transform: scale(1); @@ -1043,7 +890,6 @@ footer { transform: scale(1); } } - @keyframes float { 0% { transform: translateY(0px); @@ -1055,7 +901,6 @@ footer { transform: translateY(0px); } } - @keyframes slideInRight { from { opacity: 0; @@ -1066,7 +911,6 @@ footer { transform: translateX(0); } } - @keyframes slideInLeft { from { opacity: 0; @@ -1077,38 +921,27 @@ footer { transform: translateX(0); } } - .animate-fade-in { animation: fadeIn 0.8s ease forwards; } - .animate-pulse { animation: pulse 2s ease-in-out infinite; } - .animate-float { animation: float 3s ease-in-out infinite; } - .animate-slide-right { animation: slideInRight 0.8s ease forwards; } - .animate-slide-left { animation: slideInLeft 0.8s ease forwards; } - -/* Micro-interactions */ .hover-scale { transition: transform 0.3s ease; } - .hover-scale:hover { transform: scale(1.05); } - -/* Media query adjustments for the return to top button */ -/* Day Page Styles */ .lesson-container { background: white; border-radius: 15px; @@ -1118,29 +951,24 @@ footer { position: relative; animation: fadeIn 0.8s ease forwards; } - .lesson-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; } - .lesson-title { display: flex; align-items: center; gap: 10px; } - .lesson-title .flag { font-size: 1.5em; } - .language-selector { display: flex; gap: 10px; } - .language-btn { padding: 8px 15px; border: none; @@ -1153,14 +981,12 @@ footer { align-items: center; gap: 5px; } - .language-btn:hover, .language-btn.active { background: var(--accent-color); color: white; transform: translateY(-2px); } - .audio-player { width: 100%; margin: 20px 0; @@ -1169,15 +995,12 @@ footer { border-radius: 10px; animation: fadeIn 0.8s ease forwards; } - .audio-player audio { width: 100%; } - #audio-fallback { margin-top: 10px; } - #audio-fallback .note { background-color: rgba(52, 152, 219, 0.1); border-left: 4px solid var(--accent-color); @@ -1189,14 +1012,12 @@ footer { align-items: center; gap: 8px; } - .text-content { margin: 20px 0; line-height: 1.8; font-size: 1.1em; animation: fadeIn 0.8s ease forwards; } - .phrase-section { margin-bottom: 30px; background: var(--secondary-color); @@ -1206,7 +1027,6 @@ footer { position: relative; overflow: hidden; } - .phrase-section::before { content: ""; position: absolute; @@ -1224,7 +1044,6 @@ footer { right: -100px; top: -100px; } - .phrase-section h3 { color: var(--accent-color); margin: 0 0 15px 0; @@ -1234,7 +1053,6 @@ footer { position: relative; z-index: 1; } - .phrase-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); @@ -1242,7 +1060,6 @@ footer { position: relative; z-index: 1; } - .phrase-item { background: white; padding: 12px; @@ -1254,61 +1071,50 @@ footer { transition: all 0.3s ease; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); } - .phrase-reading { flex: 1; min-width: 0; line-height: 1.55; } - .phrase-item.audio-sync-active { box-shadow: 0 0 0 2px rgba(26, 92, 143, 0.35), 0 4px 8px rgba(0, 0, 0, 0.1); background-color: #f9fcff; } - .lesson-token { transition: background-color 0.12s ease, color 0.12s ease; } - .lesson-token.audio-sync-reading { background-color: rgba(243, 156, 18, 0.35); border-radius: 2px; } - .lesson-space { pointer-events: none; } - .reading-sync-line { margin: 0.75rem 0; line-height: 1.65; } - .reading-sync-line.audio-sync-active, .writing-sync-cue.audio-sync-active { background-color: rgba(249, 252, 255, 0.95); box-shadow: 0 0 0 2px rgba(26, 92, 143, 0.3); border-radius: 4px; } - .writing-sync-cue { display: block; margin-bottom: 0.35rem; } - .reading-passage-reading { display: inline; } - .phrase-item:hover { transform: translateX(5px); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); } - .copy-btn { background: none; border: none; @@ -1318,12 +1124,10 @@ footer { opacity: 0.7; transition: all 0.3s ease; } - .copy-btn:hover { opacity: 1; transform: scale(1.1); } - .phrase-star-btn { background: none; border: none; @@ -1333,16 +1137,13 @@ footer { opacity: 0.85; transition: all 0.3s ease; } - .phrase-star-btn:hover { opacity: 1; transform: scale(1.1); } - .phrase-star-btn[aria-pressed="true"] { color: #f39c12; } - .review-card { border: 1px solid var(--secondary-color); border-radius: 8px; @@ -1350,25 +1151,21 @@ footer { margin-bottom: 1rem; background: #fff; } - .review-phrase { font-size: 1.15rem; margin: 0 0 0.5rem; } - .review-meta { margin: 0 0 0.75rem; font-size: 0.9rem; color: #555; } - .review-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; } - .secondary-btn { background: #f0f0f0; border: 1px solid #ddd; @@ -1376,19 +1173,16 @@ footer { border-radius: 4px; cursor: pointer; } - .site-nav-links { margin: 0.5rem 0 0; text-align: center; } - .site-nav-links a { color: inherit; font-weight: 600; text-decoration: underline; margin: 0 0.45rem; } - .srs-card { background: #fff; border: 1px solid #e0e5e9; @@ -1396,48 +1190,40 @@ footer { padding: 1.25rem; margin: 1rem 0; } - .srs-front { font-size: 1.8rem; margin: 0.5rem 0; } - .srs-back { border-top: 1px solid #e0e5e9; color: #45525d; margin: 1rem 0; padding-top: 1rem; } - .practice-summary { background: var(--secondary-color); border-radius: 8px; padding: 0.75rem 1rem; } - .streak-banner h3 { display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: baseline; } - .tone-glyph-list { display: inline-flex; gap: 0.2rem; margin-left: 0.4rem; vertical-align: middle; } - .tone-glyph { width: 26px; height: 18px; } - .tone-explainer { border-left: 4px solid var(--accent-color); } - .stroke-order-panel { align-items: center; background: #fbfcfd; @@ -1450,28 +1236,23 @@ footer { padding: 1rem; width: min(100%, 260px); } - .stroke-order-writer { min-height: 180px; } - .hanzi-writer-lite { display: block; } - .navigation { display: flex; justify-content: space-between; margin: 20px 0; animation: fadeIn 0.8s ease forwards; } - .navigation:last-child { margin-top: 30px; padding-top: 20px; border-top: 1px solid var(--secondary-color); } - .nav-btn { padding: 10px 20px; border: none; @@ -1484,17 +1265,14 @@ footer { gap: 8px; transition: all 0.3s ease; } - .nav-btn:hover { background: var(--hover-color); transform: translateY(-2px); } - .nav-btn.disabled { opacity: 0.5; cursor: not-allowed; } - .home-btn { padding: 10px 20px; background: var(--primary-color); @@ -1506,12 +1284,10 @@ footer { gap: 8px; transition: all 0.3s ease; } - .home-btn:hover { background: #34495e; transform: translateY(-2px); } - .section-info { background: var(--secondary-color); padding: 15px; @@ -1521,7 +1297,6 @@ footer { position: relative; overflow: hidden; } - .section-info::before { content: ""; position: absolute; @@ -1539,21 +1314,18 @@ footer { left: -100px; bottom: -100px; } - .section-info h3 { color: var(--accent-color); margin: 0 0 10px 0; position: relative; z-index: 1; } - .section-info p { margin: 0; color: #444; position: relative; z-index: 1; } - .lesson-actions { display: flex; flex-wrap: wrap; @@ -1563,7 +1335,6 @@ footer { margin: 30px 0; animation: fadeIn 0.8s ease forwards; } - .complete-btn { padding: 12px 24px; border: none; @@ -1577,17 +1348,14 @@ footer { gap: 8px; transition: all 0.3s ease; } - .complete-btn:hover { transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); } - .complete-btn.completed { background: var(--accent-color); cursor: default; } - .copy-notification { position: fixed; bottom: 20px; @@ -1601,7 +1369,6 @@ footer { animation: fadeInOut 2s ease; z-index: 1000; } - @keyframes fadeInOut { 0% { opacity: 0; @@ -1620,34 +1387,28 @@ footer { transform: translateY(-20px); } } - @media (max-width: 768px) { .language-selector { gap: 5px; } - .language-btn { padding: 6px 10px; font-size: 0.9em; } - .lesson-header { flex-direction: column; align-items: flex-start; gap: 15px; } - .language-selector { width: 100%; justify-content: flex-start; } - .navigation { flex-direction: column; gap: 10px; align-items: stretch; } - .nav-btn, .home-btn { width: 100%; @@ -1656,20 +1417,17 @@ footer { font-size: 0.9em; } } - @media (max-width: 480px) { .language-selector { flex-wrap: wrap; justify-content: flex-start; gap: 8px; } - .language-btn { padding: 5px 8px; font-size: 0.8em; } } - @media (max-width: 768px) { .return-to-top { bottom: 70px; @@ -1677,8 +1435,85 @@ footer { width: 40px; height: 40px; } - body { - padding-bottom: 0; /* Remove padding since footer is no longer fixed */ + padding-bottom: 0; } } +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} +.modal-content { + background: white; + padding: 2rem; + border-radius: 8px; + max-width: 400px; + width: 90%; +} +.settings-form { + display: flex; + flex-direction: column; + gap: 1rem; +} +.form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.button-group { + display: flex; + gap: 1rem; + justify-content: flex-end; + margin-top: 1rem; +} +.primary-btn { + background: var(--primary-color); + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; + font-family: inherit; + font-size: inherit; +} +.level-selector { + margin: 1.5rem 0; +} +.level-selector h3 { + font-size: 1rem; + margin-bottom: 0.75rem; + color: var(--text-color); +} +.level-buttons { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} +.level-btn { + padding: 0.4rem 0.9rem; + border-radius: 20px; + border: 2px solid var(--accent-color); + background: transparent; + color: var(--accent-color); + text-decoration: none; + font-size: 0.875rem; + font-family: inherit; + transition: + background 0.2s, + color 0.2s; + display: inline-block; + cursor: pointer; +} +.level-btn.active, +.level-btn:hover { + background: var(--accent-color); + color: white; +} diff --git a/day.html b/day.html index d8f891e..adbd5fc 100644 --- a/day.html +++ b/day.html @@ -25,12 +25,26 @@ +
@@ -214,6 +228,7 @@

+ diff --git a/index.html b/index.html index 68f34c9..92a1017 100644 --- a/index.html +++ b/index.html @@ -937,7 +937,7 @@

class="primary-btn" aria-label="Export progress JSON" > - Export + 导出Export

+ +
+ @@ -93,6 +142,8 @@

Find your starting point

+ + diff --git a/quiz.html b/quiz.html index 5bb9cf2..d9aedfd 100644 --- a/quiz.html +++ b/quiz.html @@ -1,5 +1,5 @@ - + +
+
+ + + +

Mandarin Pathways

自我测验Mandarin Pathways + +

+
@@ -98,6 +150,8 @@

Select a day

+ + diff --git a/reading.html b/reading.html index 7ac54d7..cb7b6e3 100644 --- a/reading.html +++ b/reading.html @@ -214,6 +214,8 @@

+ + diff --git a/review.html b/review.html index c7de00f..bcda918 100644 --- a/review.html +++ b/review.html @@ -25,15 +25,52 @@ +
+
+ + + +

Mandarin Pathways

复习收藏的短语Mandarin Pathways

+ + diff --git a/srs.html b/srs.html index f96e8d9..27c721f 100644 --- a/srs.html +++ b/srs.html @@ -12,6 +12,10 @@ /> + Mandarin Pathways - SRS Review @@ -21,15 +25,52 @@ +
+
+ + + +

Mandarin Pathways

间隔重复复习Mandarin Pathways

+ +
+ +
+ + +
+
+ + diff --git a/sw.js b/sw.js index ba06047..74b8e9b 100644 --- a/sw.js +++ b/sw.js @@ -1,4 +1,4 @@ -const CACHE_VERSION = "11"; +const CACHE_VERSION = "12"; const CACHE_NAME = `mandarin-pathways-v${CACHE_VERSION}`; // Cache groups for different types of resources @@ -32,6 +32,8 @@ const STATIC_ASSETS = [ "js/writing-page.js", "js/supplementary-page.js", "js/data-portability.js", + "js/i18n.js", + "js/lang-switcher.js", "js/review-page.js", "js/streaks.js", "js/srs.js", diff --git a/tests/e2e/localization.spec.ts b/tests/e2e/localization.spec.ts new file mode 100644 index 0000000..8c298a7 --- /dev/null +++ b/tests/e2e/localization.spec.ts @@ -0,0 +1,161 @@ +import { test, expect } from "@playwright/test"; + +test.describe("page localization", () => { + test("placement static and result UI follow lang", async ({ page }) => { + await page.goto("/placement.html?lang=zh", { waitUntil: "load" }); + await expect(page.locator("#placement-title .zh")).toHaveText( + "找到你的起点", + ); + await expect(page.locator("#placement-title .en")).toBeHidden(); + + await page.evaluate(() => { + localStorage.setItem( + "placementResult", + JSON.stringify({ + score: 5, + total: 10, + percentage: 50, + level: "Upper beginner", + recommendedDay: 8, + message: { + zh: "从第一周后开始学习,并根据需要复习前面的课程。", + en: "Begin after the first week and review earlier lessons as needed.", + }, + }), + ); + }); + await page.reload({ waitUntil: "load" }); + await expect(page.locator("#placement-result h2 .zh")).toContainText( + "建议起点:第 8 天", + ); + await expect(page.locator("#placement-result h2 .en")).toBeHidden(); + + await page.goto("/placement.html?lang=en", { waitUntil: "load" }); + await expect(page.locator("#placement-title .en")).toHaveText( + "Find your starting point", + ); + await expect(page.locator("#placement-title .zh")).toBeHidden(); + }); + + test("quiz static UI follows selected language", async ({ page }) => { + await page.goto("/quiz.html?day=1&lang=zh", { waitUntil: "load" }); + await expect(page.locator("#quiz-title .zh")).toHaveText("按天测验"); + await expect(page.locator("#quiz-title .en")).toBeHidden(); + + await page.goto("/quiz.html?day=1&lang=en", { waitUntil: "load" }); + await expect(page.locator("#quiz-title .en")).toHaveText("Quiz by day"); + await expect(page.locator("#quiz-title .zh")).toBeHidden(); + }); + + test("quiz result UI follows lang after submit", async ({ page }) => { + await page.goto("/quiz.html?day=1&lang=zh", { waitUntil: "load" }); + await page.locator('input[name="question-0"][value="How are you?"]').check(); + await page.locator('input[name="question-1"][value="zai jian"]').check(); + await page.locator('input[name="question-2"]').fill("我"); + await page.locator("#quiz-submit").click(); + await expect(page.locator("#quiz-result .zh").first()).toContainText( + "第 1 天得分", + ); + await expect(page.locator("#quiz-result .en").first()).toBeHidden(); + }); + + test("supplementary complete action follows lang", async ({ page }) => { + await page.goto( + "/supplementary.html?category=education&lang=zh", + { waitUntil: "load" }, + ); + await page.locator("#complete-btn").click(); + await expect(page.locator("#complete-btn .zh")).toHaveText("已完成"); + await expect(page.locator("#complete-btn .en")).toBeHidden(); + await expect(page.locator("#copy-notification .zh")).toHaveText( + "分类已标记为完成!", + ); + }); + + test("reading complete action follows lang", async ({ page }) => { + await page.goto( + "/reading.html?level=beginner&topic=Self%20Introduction&lang=zh", + { waitUntil: "load" }, + ); + await expect(page.locator("#complete-btn")).toBeVisible({ + timeout: 15000, + }); + await page.locator("#complete-btn").click(); + await expect(page.locator("#copy-notification .zh")).toHaveText( + "阅读已标记为完成!", + ); + await expect(page.locator("#copy-notification .en")).toBeHidden(); + }); + + test("writing complete action follows pinyin mode", async ({ page }) => { + await page.goto( + "/writing.html?type=character&level=Basic%20Strokes&lang=pinyin", + { waitUntil: "load" }, + ); + await expect(page.locator("#complete-btn")).toBeVisible({ + timeout: 15000, + }); + await page.locator("#complete-btn").click(); + await expect(page.locator("#complete-btn .pinyin")).toHaveText( + "Yǐ wánchéng", + ); + await expect(page.locator("#complete-btn .zh")).toBeHidden(); + await expect(page.locator("#complete-btn .en")).toBeHidden(); + }); + + test("review remove button follows lang", async ({ page }) => { + await page.goto("/review.html?lang=zh", { waitUntil: "load" }); + await page.evaluate(() => { + localStorage.setItem( + "starredPhrases", + JSON.stringify([ + { + id: "test-star", + phrase: "你好", + day: 1, + lang: "zh", + createdAt: new Date().toISOString(), + }, + ]), + ); + }); + await page.reload({ waitUntil: "load" }); + await expect( + page.locator(".review-actions button .zh"), + ).toHaveText("移除"); + await expect( + page.locator(".review-actions button .en"), + ).toBeHidden(); + }); + + test("srs session controls follow lang", async ({ page }) => { + await page.goto("/srs.html?lang=zh", { waitUntil: "load" }); + await page.evaluate(() => { + localStorage.setItem( + "srsCards", + JSON.stringify([ + { + id: "loc-card", + day: 1, + lang: "zh", + front: "你好", + back: "Hello", + due: new Date(Date.now() - 1000).toISOString(), + interval: 0, + ease: 2.5, + reps: 0, + }, + ]), + ); + }); + await page.reload({ waitUntil: "load" }); + await expect(page.locator('[data-testid="srs-show-answer"] .zh')).toHaveText( + "显示答案", + ); + await expect(page.locator('[data-testid="srs-show-answer"] .en')).toBeHidden(); + await page.locator('[data-testid="srs-show-answer"]').click(); + await expect(page.locator('[data-testid="srs-grade-good"] .zh')).toHaveText( + "良好", + ); + }); +}); diff --git a/tests/e2e/retention.spec.ts b/tests/e2e/retention.spec.ts index 0130c6a..fcb610a 100644 --- a/tests/e2e/retention.spec.ts +++ b/tests/e2e/retention.spec.ts @@ -24,8 +24,8 @@ test.describe("retention loop", () => { await page.reload(); await expect(page.locator(".srs-front")).toContainText("你好"); - await page.getByRole("button", { name: "Show answer" }).click(); - await page.getByRole("button", { name: "Good" }).click(); + await page.locator('[data-testid="srs-show-answer"]').click(); + await page.locator('[data-testid="srs-grade-good"]').click(); const stored = await page.evaluate(() => JSON.parse(localStorage.getItem("srsCards") || "[]"), @@ -48,6 +48,18 @@ test.describe("retention loop", () => { await page.goto("/"); await page.evaluate(() => { localStorage.setItem("streakCount", "3"); + localStorage.setItem( + "completedDays", + JSON.stringify({ + "1_zh": true, + }), + ); + localStorage.setItem( + "placementResult", + JSON.stringify({ + recommendedDay: 8, + }), + ); localStorage.setItem( "srsCards", JSON.stringify([ @@ -66,5 +78,26 @@ test.describe("retention loop", () => { "3", ); await expect(page.locator("#srs-due-count")).toHaveText("1"); + await expect(page.locator("#placement-start-day .zh")).toHaveText( + "第8天", + ); + await expect( + page.locator(".achievement-badge").first().locator(".zh").first(), + ).toHaveText("第一课"); + await expect( + page.locator(".achievement-badge").first().locator(".en").first(), + ).toBeHidden(); + + await page.getByRole("button", { name: "English" }).click(); + + await expect(page.locator("#placement-start-day .en")).toHaveText( + "Day 8", + ); + await expect( + page.locator(".achievement-badge").first().locator(".en").first(), + ).toHaveText("First lesson"); + await expect( + page.locator(".achievement-badge").first().locator(".zh").first(), + ).toBeHidden(); }); }); diff --git a/tests/e2e/self-assessment.spec.ts b/tests/e2e/self-assessment.spec.ts index 8eaca7b..d573838 100644 --- a/tests/e2e/self-assessment.spec.ts +++ b/tests/e2e/self-assessment.spec.ts @@ -2,11 +2,9 @@ import { test, expect } from "@playwright/test"; test.describe("Self assessment", () => { test("placement test stores a recommendation", async ({ page }) => { - await page.goto("/placement.html"); + await page.goto("/placement.html?lang=en"); - await expect( - page.getByRole("heading", { name: /Find your starting point/i }), - ).toBeVisible(); + await expect(page.locator("#placement-title .en")).toBeVisible(); const answers = [ "Hello", @@ -25,8 +23,8 @@ test.describe("Self assessment", () => { await page.getByLabel(answer).check(); } - await page.getByRole("button", { name: /Score placement/i }).click(); - await expect(page.getByTestId("placement-result")).toContainText( + await page.locator("#placement-submit").click(); + await expect(page.locator("#placement-result h2 .en")).toContainText( "Recommended start: Day 31", ); @@ -38,17 +36,15 @@ test.describe("Self assessment", () => { }); test("day quiz stores the best score", async ({ page }) => { - await page.goto("/quiz.html?day=1"); + await page.goto("/quiz.html?day=1&lang=en"); - await expect( - page.getByRole("heading", { name: /Quiz by day/i }), - ).toBeVisible(); + await expect(page.locator("#quiz-title .en")).toBeVisible(); await page.getByLabel("How are you?").check(); await page.getByLabel("zai jian").check(); await page.getByTestId("quiz-fill-3").fill("我"); - await page.getByRole("button", { name: /Score quiz/i }).click(); - await expect(page.getByTestId("quiz-result")).toContainText( + await page.locator("#quiz-submit").click(); + await expect(page.locator("#quiz-result h2 .en")).toContainText( "Day 1 score: 3/3", ); diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts index 44945e6..1ec5fea 100644 --- a/tests/e2e/smoke.spec.ts +++ b/tests/e2e/smoke.spec.ts @@ -31,6 +31,35 @@ test.describe("PWA smoke", () => { await expect(page.locator("#complete-btn")).toBeVisible(); }); + test("day lesson dynamic UI follows selected language", async ({ + page, + }) => { + await page.goto("/day.html?day=1&lang=zh", { waitUntil: "load" }); + await expect(page.locator("#section-title .zh")).toHaveText( + "拼音系统与发音", + ); + await expect(page.locator("#section-title .en")).toBeHidden(); + + await page.locator("#complete-btn").click(); + await expect(page.locator("#complete-btn .zh")).toHaveText("已完成"); + await expect(page.locator("#complete-btn .en")).toBeHidden(); + await expect(page.locator("#copy-notification .zh")).toHaveText( + "已标记为完成!", + ); + + await page.goto("/day.html?day=8&lang=en", { waitUntil: "load" }); + await expect(page.locator("#section-title .en")).toHaveText( + "Essential Daily Phrases", + ); + await expect(page.locator("#section-title .zh")).toBeHidden(); + + await page.goto("/day.html?day=1&lang=pinyin", { waitUntil: "load" }); + await expect(page.locator("#audio-fallback .zh")).toHaveText( + "使用普通话音频作为参考。", + ); + await expect(page.locator("#audio-fallback .en")).toBeHidden(); + }); + test("writing shell renders with URL selection", async ({ page }) => { await page.goto( "/writing.html?type=character&level=Basic%20Strokes&lang=en", diff --git a/writing.html b/writing.html index 880356d..260b9a0 100644 --- a/writing.html +++ b/writing.html @@ -11,6 +11,7 @@ content="Practice writing Chinese characters - 练习写汉字" /> + + + From d7db846d37199f54ea3ad7b5ad504f6a078c6737 Mon Sep 17 00:00:00 2001 From: Donnivis Baker Date: Tue, 9 Jun 2026 13:18:47 -0400 Subject: [PATCH 4/5] Add SRS vocab button, tests, and lint fixes Add an SRS button to reading vocab items that calls upsertSrsCard and shows a notification, and include js/srs.js in reading.html. Update formatAndDisplayReadingContent to accept level/topic and pass those through for SRS IDs. Apply small formatting/line-wrap cleanups across multiple JS files (i18n, day/supplementary/character-drawing/writing/lang-switcher) and fix some HTML span line-breaks in index.html. Adjust e2e tests formatting and add smoke tests for pinyin tone glyph rendering and the writing stroke-order panel. No dependency lock files changed. --- index.html | 9 +++-- js/character-drawing.js | 8 +++- js/day-page.js | 7 ++-- js/i18n.js | 33 ++++++++++------ js/lang-switcher.js | 3 +- js/reading-page.js | 71 +++++++++++++++++++++++++++------- js/supplementary-page.js | 10 +++-- js/writing-page.js | 5 ++- reading.html | 1 + tests/e2e/localization.spec.ts | 41 +++++++++++--------- tests/e2e/smoke.spec.ts | 18 +++++++++ 11 files changed, 148 insertions(+), 58 deletions(-) diff --git a/index.html b/index.html index 92a1017..c309e19 100644 --- a/index.html +++ b/index.html @@ -937,7 +937,8 @@

class="primary-btn" aria-label="Export progress JSON" > - 导出Export + 导出Export

diff --git a/js/character-drawing.js b/js/character-drawing.js index 5c4672b..e52345e 100644 --- a/js/character-drawing.js +++ b/js/character-drawing.js @@ -511,8 +511,12 @@ function initializeCharacterDrawing() { area.appendChild(drawingContainer); }); - const lang = new URLSearchParams(window.location.search).get("lang") || "zh"; - applyWritingLangVisibility(lang, document.getElementById("writing-content")); + const lang = + new URLSearchParams(window.location.search).get("lang") || "zh"; + applyWritingLangVisibility( + lang, + document.getElementById("writing-content"), + ); } // Initialize when DOM is loaded diff --git a/js/day-page.js b/js/day-page.js index d9100d8..b37c67c 100644 --- a/js/day-page.js +++ b/js/day-page.js @@ -130,11 +130,12 @@ document.addEventListener("DOMContentLoaded", function () { const audioFallback = document.getElementById("audio-fallback"); if (lang === "pinyin") { - audioFallback.innerHTML = - `

${localizedTextHtml({ + audioFallback.innerHTML = `

${localizedTextHtml( + { zh: "使用普通话音频作为参考。", en: "Using Mandarin audio for reference.", - })}

`; + }, + )}

`; audioFallback.style.display = "block"; } else { audioFallback.innerHTML = ""; diff --git a/js/i18n.js b/js/i18n.js index c04afb6..5a70d93 100644 --- a/js/i18n.js +++ b/js/i18n.js @@ -11,18 +11,22 @@ function localizedTextHtml3(text) { } function renderCompleteButtonCompleted(button) { - button.innerHTML = ` ${localizedTextHtml({ - zh: "已完成", - en: "Completed", - })}`; + button.innerHTML = ` ${localizedTextHtml( + { + zh: "已完成", + en: "Completed", + }, + )}`; } function renderCompleteButtonCompleted3(button) { - button.innerHTML = ` ${localizedTextHtml3({ - zh: "已完成", - pinyin: "Yǐ wánchéng", - en: "Completed", - })}`; + button.innerHTML = ` ${localizedTextHtml3( + { + zh: "已完成", + pinyin: "Yǐ wánchéng", + en: "Completed", + }, + )}`; } function renderCopyNotificationDefault(notification) { @@ -40,7 +44,12 @@ function renderCopyNotificationDefault3(notification) { }); } -function showTimedNotification(notification, messageHtml, resetHtml, ms = 2000) { +function showTimedNotification( + notification, + messageHtml, + resetHtml, + ms = 2000, +) { notification.innerHTML = messageHtml; notification.style.display = "block"; notification.style.animation = "none"; @@ -57,7 +66,9 @@ function applyStandardDocumentLang(lang) { } function getUrlLang(defaultLang = "zh") { - return new URLSearchParams(window.location.search).get("lang") || defaultLang; + return ( + new URLSearchParams(window.location.search).get("lang") || defaultLang + ); } function applyWritingLangVisibility(lang, root = document) { diff --git a/js/lang-switcher.js b/js/lang-switcher.js index 5910252..e60d1d1 100644 --- a/js/lang-switcher.js +++ b/js/lang-switcher.js @@ -16,7 +16,8 @@ function applyLang(lang) { lang === "en" ? "en" : lang === "pinyin" ? "zh-CN" : lang; document.querySelectorAll(".language-btn").forEach(function (btn) { const matches = - (btn.dataset.lang === "zh" && (lang === "zh-CN" || lang === "zh")) || + (btn.dataset.lang === "zh" && + (lang === "zh-CN" || lang === "zh")) || btn.dataset.lang === lang; btn.classList.toggle("active", matches); }); diff --git a/js/reading-page.js b/js/reading-page.js index 6f26583..c3c8d14 100644 --- a/js/reading-page.js +++ b/js/reading-page.js @@ -190,20 +190,24 @@ function loadReadingContent(level, topic, lang) { if (topicInfo.hasAudio) { audio.src = `audio_files/reading/${level}_${topicSlug}_${audioLang}.mp3`; if (lang === "pinyin") { - audioFallback.innerHTML = `

${localizedTextHtml({ - zh: "使用普通话音频作为参考。", - en: "Using Mandarin audio for reference.", - })}

`; + audioFallback.innerHTML = `

${localizedTextHtml( + { + zh: "使用普通话音频作为参考。", + en: "Using Mandarin audio for reference.", + }, + )}

`; audioFallback.style.display = "block"; } else { audioFallback.style.display = "none"; } } else { audio.src = ""; - audioFallback.innerHTML = `

${localizedTextHtml({ - zh: "此阅读暂无音频。", - en: "Audio not available for this reading.", - })}

`; + audioFallback.innerHTML = `

${localizedTextHtml( + { + zh: "此阅读暂无音频。", + en: "Audio not available for this reading.", + }, + )}

`; audioFallback.style.display = "block"; } @@ -234,7 +238,7 @@ function loadReadingContent(level, topic, lang) { Promise.all([textFetch, timingFetch]) .then(([text, timing]) => { - formatAndDisplayReadingContent(text, lang, timing); + formatAndDisplayReadingContent(text, lang, timing, level, topic); if (topicInfo.hasAudio) { LessonAudioSync.attachCueHighlighting( audio, @@ -255,7 +259,13 @@ function loadReadingContent(level, topic, lang) { }); } -function formatAndDisplayReadingContent(text, lang, timingManifest) { +function formatAndDisplayReadingContent( + text, + lang, + timingManifest, + level, + topic, +) { // Split the text into sections (main text, vocabulary, questions) const sections = text.split(/\n(?=\w[^\n]+\n-+\n)/); @@ -323,12 +333,43 @@ function formatAndDisplayReadingContent(text, lang, timingManifest) { .replace(/\(([^)]+)\)/, '($1)') .replace(/-/, '-'); + const srsBtn = document.createElement("button"); + srsBtn.type = "button"; + srsBtn.className = "copy-btn"; + srsBtn.setAttribute("aria-label", "Add to SRS review"); + srsBtn.innerHTML = ''; + srsBtn.addEventListener("click", () => { + if (typeof upsertSrsCard !== "function") return; + const front = item.replace(/^[•\s]+/, "").trim(); + upsertSrsCard({ + id: `reading|${level}|${topic}|${lang}|${front}`, + day: null, + lang, + front, + back: `${level} - ${topic}`, + }); + const notification = + document.getElementById("copy-notification"); + showTimedNotification( + notification, + localizedTextHtml({ + zh: "短语已添加到间隔复习。", + en: "Phrase added to SRS review.", + }), + localizedTextHtml({ + zh: "短语已复制到剪贴板!", + en: "Phrase copied to clipboard!", + }), + ); + }); + const copyBtn = document.createElement("button"); copyBtn.className = "copy-btn"; copyBtn.innerHTML = ''; copyBtn.addEventListener("click", () => copyText(item.trim())); vocabItem.appendChild(itemText); + vocabItem.appendChild(srsBtn); vocabItem.appendChild(copyBtn); vocabListDiv.appendChild(vocabItem); } @@ -388,10 +429,12 @@ function formatAndDisplayReadingContent(text, lang, timingManifest) { const answerToggle = document.createElement("button"); answerToggle.className = "answer-toggle"; - answerToggle.innerHTML = ` ${localizedTextHtml({ - zh: "显示答案", - en: "Show Answer", - })}`; + answerToggle.innerHTML = ` ${localizedTextHtml( + { + zh: "显示答案", + en: "Show Answer", + }, + )}`; const answerText = document.createElement("div"); answerText.className = "answer-text"; diff --git a/js/supplementary-page.js b/js/supplementary-page.js index 2dfbcc1..874d3f8 100644 --- a/js/supplementary-page.js +++ b/js/supplementary-page.js @@ -84,10 +84,12 @@ document.addEventListener("DOMContentLoaded", function () { // Add a note only for Pinyin that it's using Mandarin audio const audioFallback = document.getElementById("audio-fallback"); if (lang === "pinyin") { - audioFallback.innerHTML = `

${localizedTextHtml({ - zh: "使用普通话音频作为参考。", - en: "Using Mandarin audio for reference.", - })}

`; + audioFallback.innerHTML = `

${localizedTextHtml( + { + zh: "使用普通话音频作为参考。", + en: "Using Mandarin audio for reference.", + }, + )}

`; audioFallback.style.display = "block"; } else { audioFallback.innerHTML = ""; diff --git a/js/writing-page.js b/js/writing-page.js index fedf32e..0033fea 100644 --- a/js/writing-page.js +++ b/js/writing-page.js @@ -256,7 +256,10 @@ document.addEventListener("DOMContentLoaded", function () { document.querySelector(".level-selector").style.display = "block"; document.getElementById("activity-type").innerHTML = getActivityTypeDisplayHtml(activityType); - applyWritingLangVisibility(lang, document.getElementById("activity-type")); + applyWritingLangVisibility( + lang, + document.getElementById("activity-type"), + ); // Populate levels const levelButtons = document.getElementById("level-buttons"); diff --git a/reading.html b/reading.html index cb7b6e3..f86d0b1 100644 --- a/reading.html +++ b/reading.html @@ -218,6 +218,7 @@

+ diff --git a/tests/e2e/localization.spec.ts b/tests/e2e/localization.spec.ts index 8c298a7..c8804cd 100644 --- a/tests/e2e/localization.spec.ts +++ b/tests/e2e/localization.spec.ts @@ -49,8 +49,12 @@ test.describe("page localization", () => { test("quiz result UI follows lang after submit", async ({ page }) => { await page.goto("/quiz.html?day=1&lang=zh", { waitUntil: "load" }); - await page.locator('input[name="question-0"][value="How are you?"]').check(); - await page.locator('input[name="question-1"][value="zai jian"]').check(); + await page + .locator('input[name="question-0"][value="How are you?"]') + .check(); + await page + .locator('input[name="question-1"][value="zai jian"]') + .check(); await page.locator('input[name="question-2"]').fill("我"); await page.locator("#quiz-submit").click(); await expect(page.locator("#quiz-result .zh").first()).toContainText( @@ -60,10 +64,9 @@ test.describe("page localization", () => { }); test("supplementary complete action follows lang", async ({ page }) => { - await page.goto( - "/supplementary.html?category=education&lang=zh", - { waitUntil: "load" }, - ); + await page.goto("/supplementary.html?category=education&lang=zh", { + waitUntil: "load", + }); await page.locator("#complete-btn").click(); await expect(page.locator("#complete-btn .zh")).toHaveText("已完成"); await expect(page.locator("#complete-btn .en")).toBeHidden(); @@ -120,12 +123,10 @@ test.describe("page localization", () => { ); }); await page.reload({ waitUntil: "load" }); - await expect( - page.locator(".review-actions button .zh"), - ).toHaveText("移除"); - await expect( - page.locator(".review-actions button .en"), - ).toBeHidden(); + await expect(page.locator(".review-actions button .zh")).toHaveText( + "移除", + ); + await expect(page.locator(".review-actions button .en")).toBeHidden(); }); test("srs session controls follow lang", async ({ page }) => { @@ -149,13 +150,15 @@ test.describe("page localization", () => { ); }); await page.reload({ waitUntil: "load" }); - await expect(page.locator('[data-testid="srs-show-answer"] .zh')).toHaveText( - "显示答案", - ); - await expect(page.locator('[data-testid="srs-show-answer"] .en')).toBeHidden(); + await expect( + page.locator('[data-testid="srs-show-answer"] .zh'), + ).toHaveText("显示答案"); + await expect( + page.locator('[data-testid="srs-show-answer"] .en'), + ).toBeHidden(); await page.locator('[data-testid="srs-show-answer"]').click(); - await expect(page.locator('[data-testid="srs-grade-good"] .zh')).toHaveText( - "良好", - ); + await expect( + page.locator('[data-testid="srs-grade-good"] .zh'), + ).toHaveText("良好"); }); }); diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts index 1ec5fea..76c636a 100644 --- a/tests/e2e/smoke.spec.ts +++ b/tests/e2e/smoke.spec.ts @@ -70,4 +70,22 @@ test.describe("PWA smoke", () => { await expect(page.locator("#writing-content")).toBeAttached(); await expect(page.locator("#complete-btn")).toBeVisible(); }); + + test("tone glyphs render in pinyin mode on day.html", async ({ page }) => { + await page.goto("/day.html?day=1&lang=pinyin", { waitUntil: "load" }); + await expect(page.locator(".tone-glyph").first()).toBeVisible({ + timeout: 5000, + }); + expect(await page.locator(".tone-glyph").count()).toBeGreaterThan(0); + }); + + test("stroke-order panel renders on writing.html", async ({ page }) => { + await page.goto( + "/writing.html?type=character&level=Basic%20Strokes&lang=en", + { waitUntil: "load" }, + ); + const panel = page.locator(".stroke-order-panel").first(); + await expect(panel).toBeVisible({ timeout: 5000 }); + await expect(panel.locator("svg").first()).toBeAttached(); + }); }); From a0475c52f0eeb29b67fe0cac3f68b2b5ebbafa3e Mon Sep 17 00:00:00 2001 From: Donnivis Baker Date: Tue, 9 Jun 2026 13:31:23 -0400 Subject: [PATCH 5/5] Normalize preferred language and add test Introduce normalizePreferredLanguage and use it across i18n, lang-switcher, and script to ensure consistent handling of 'zh' vs 'zh-CN'. Update document lang management, language button activation, and selectedLanguage initialization; adjust progress UI to render both zh/en variants when present. Add a Playwright localization test to verify URL-driven language and localStorage behavior. Also remove the obsolete ROADMAP.md. --- ROADMAP.md | 110 --------------------------------- js/i18n.js | 10 ++- js/lang-switcher.js | 18 +++--- js/script.js | 37 ++++++----- js/script.min.js | 37 ++++++----- tests/e2e/localization.spec.ts | 18 ++++++ 6 files changed, 80 insertions(+), 150 deletions(-) delete mode 100644 ROADMAP.md diff --git a/ROADMAP.md b/ROADMAP.md deleted file mode 100644 index 0c50338..0000000 --- a/ROADMAP.md +++ /dev/null @@ -1,110 +0,0 @@ -# Gap-Closing Roadmap — Mandarin Pathways → toward the "industry standard" - -## Context - -`industry-standard.md` describes an aspirational super-app ("Mandarin Mastery") with AI conversation, speech -recognition, OCR, adaptive learning, SRS, gamification, and a subscription business — backed by a Node/Python + -Postgres + AWS stack. The **actual** project (Mandarin Pathways) is a strong but narrower product: an offline-first, -trilingual (zh / pinyin / en) 40-day course delivered as a vanilla-JS **PWA** and a **Flutter** app, with **no -backend, no database, no auth**. Of the ~18 flagship features in the standard, ~3 are fully present, ~5 partial, ~10 -absent. - -This roadmap closes the **highest-leverage gaps that do NOT require a backend**, so they can ship within the current -offline architecture. Per the user's decision, work targets the **Web PWA first**; Flutter parity is a later phase. -Out of scope (require server/LLM infra, tracked separately): AI conversation partner, cloud sync/accounts, -leaderboards, language exchange, subscriptions. Speech-recognition pronunciation feedback is a _stretch_ item (browser -Web Speech API is client-side but network-dependent and inconsistent). - -**Intended outcome:** move from "static courseware" toward "engaging, retention-driven self-study app" by adding the -retention loop (SRS, streaks), self-assessment (placement + quizzes), and two learning-depth features (tone visuals, -stroke-order animation) — all offline, all in the PWA. - -## Guiding constraints - -- **No backend.** Everything persists in `localStorage` (web) following existing patterns. -- **Reuse existing patterns**, don't reinvent: - - localStorage feature module pattern: `js/starred-phrases.js` (get/save/toggle + stable composite ID). - - Import/export of progress keys: `js/data-portability.js` (must be updated to include any new keys). - - Canvas practice: `js/character-drawing.js` (note line ~423: "We don't have stroke data yet" — the hook for animation). - - Page bootstrapping pattern: `js/day-page.js`, `js/reading-page.js`, `js/writing-page.js`. - - Service worker cache list: `sw.js` (bump cache version + add any new JS/CSS/page files). -- **Add tests as you go** — the repo has only ~6 Playwright smoke tests (`tests/e2e/smoke.spec.ts`). Each new page/feature - gets at least one smoke test. (A lightweight `.github/workflows/test.yml` running `npm test` is a recommended enabler.) - -## Phase 1 — Retention loop (highest leverage) - -**1a. Spaced-Repetition flashcards (SRS).** Closes the "Anki-style SRS: ABSENT" gap. - -- New `js/srs.js` modeled on `js/starred-phrases.js`: store per-card scheduling state under a new key `srsCards` - (`{ id, day, lang, front, back, due, interval, ease, reps }`). Use a small SM-2-lite algorithm (interval/ease update - on a 3-button "Again / Good / Easy" grade). -- Seed cards from existing data: starred phrases (`getStarredPhrases()`) + lesson phrases + reading vocabulary lists - (already structured in `reading_activities.py` output / `reading_files/`). -- New `srs.html` review page (clone structure of `review.html`); link from home (`index.html`) and the review page. -- Update `js/data-portability.js` to export/import the `srsCards` key; add `srs.html`/`js/srs.js` to `sw.js`. - -**1b. Streaks & achievements.** Closes "streaks/achievements: ABSENT." - -- New `js/streaks.js`: track `lastActiveDate` and `streakCount` in localStorage; increment on any lesson/SRS activity, - reset on a missed day. Simple achievement badges derived from existing `completedDays` (e.g., 7/14/30/40-day, first - SRS review, etc.) — no new data source needed. -- Surface streak + badges on the home dashboard (`index.html` progress section) and as a small banner on `day.html`. -- Add keys to `js/data-portability.js`. - -## Phase 2 — Self-assessment - -**2a. Placement test.** Closes "placement test: ABSENT." - -- New `placement.html` + `js/placement-page.js`: ~8–12 static multiple-choice questions drawn from existing phrase/vocab - content across difficulty bands; map score → recommended starting day/section. Store `placementResult` in localStorage; - offer a "Start at Day N" CTA on the home page. - -**2b. HSK self-quiz / mock test.** Closes "HSK mock tests: ABSENT" (partial). - -- New `js/quiz.js` reusable quiz engine (multiple-choice + fill-in) sourced from existing day phrases and reading vocab; - embed a "Quiz me on this day" button on `day.html` and a standalone `quiz.html`. Store best scores per day. - -## Phase 3 — Learning depth - -**3a. Tone visualization.** Closes "tone training: ABSENT." - -- New `js/tone-visualizer.js`: render the 4 Mandarin tone pitch-contour shapes (flat / rising / dip / falling) as small - inline SVG/canvas glyphs next to pinyin syllables on `day.html`. Pure static rendering keyed off the tone digit in - pinyin — no audio analysis, no backend. Add a "Tones 101" explainer card. - -**3b. Stroke-order animation.** Closes "stroke-order animations: ABSENT." - -- Integrate **Hanzi Writer** (MIT, fully client-side, bundles its own stroke data — no backend) into the writing flow. - Replace/augment the static hint in `js/character-drawing.js` (the line ~423 TODO) with animated stroke-order playback - and quiz mode. Vendor the library locally and add to `sw.js` so it stays offline. - -## Phase 4 — Flutter parity (later) - -Once Phase 1–3 land and stabilize in the PWA, port each feature to the Flutter app using its existing -`StorageService` pattern (`flutter_app/lib/services/storage_service.dart` — JSON-encoded values in SharedPreferences, -mirroring the localStorage keys). Reuse the same key names so exported web data and Flutter data stay conceptually aligned. - -## Stretch (optional, evaluate later) - -- **Pronunciation feedback** via the browser Web Speech API (`SpeechRecognition`) comparing recognized text to the target - phrase. Client-side but network-dependent and Chrome-biased — prototype before committing. -- **Searchable offline dictionary**: aggregate all phrase + reading-vocab data into one indexed glossary page (upgrades - the "dictionary: PARTIAL" gap without external data). - -## Also recommended (separate, not features) - -- **Fix `industry-standard.md`**: its tech-stack section (React Native / Postgres / AWS) and metrics are fiction relative - to the repo. Re-baseline it (or fold into a real roadmap) so the "standard" is honest and achievable. -- **Add CI** (`.github/workflows/test.yml`) running `npm test` on PR — currently no GitHub Actions exist; this protects - every feature above. - -## Verification - -- **Per feature:** add a Playwright smoke test in `tests/e2e/` (page loads, core control works, localStorage key written), - following `tests/e2e/smoke.spec.ts`. Run `python3 server.py` then `npm test` (config: `playwright.config.ts`, - baseURL `http://127.0.0.1:8000`). -- **Manual:** `python3 server.py` → exercise each new page in-browser; confirm offline behavior by loading once, going - offline (DevTools), and reloading (service worker must serve the new files — verify `sw.js` cache version was bumped). -- **Data portability:** export progress via `js/data-portability.js`, confirm new keys (`srsCards`, streak keys, - `placementResult`, quiz scores) round-trip through import. -- **Regression:** existing smoke tests still pass; `CHANGELOG.md` updated per repo convention. diff --git a/js/i18n.js b/js/i18n.js index 5a70d93..d52207d 100644 --- a/js/i18n.js +++ b/js/i18n.js @@ -61,8 +61,16 @@ function showTimedNotification( }, ms); } +function normalizePreferredLanguage(lang) { + if (!lang || lang === "zh") { + return "zh-CN"; + } + return lang; +} + function applyStandardDocumentLang(lang) { - document.documentElement.lang = lang === "en" ? "en" : "zh-CN"; + document.documentElement.lang = + normalizePreferredLanguage(lang) === "en" ? "en" : "zh-CN"; } function getUrlLang(defaultLang = "zh") { diff --git a/js/lang-switcher.js b/js/lang-switcher.js index e60d1d1..5a513db 100644 --- a/js/lang-switcher.js +++ b/js/lang-switcher.js @@ -1,10 +1,14 @@ document.addEventListener("DOMContentLoaded", function () { - const stored = localStorage.getItem("preferredLanguage") || "zh-CN"; + const stored = normalizePreferredLanguage( + localStorage.getItem("preferredLanguage") || "zh-CN", + ); applyLang(stored); document.querySelectorAll(".language-btn").forEach(function (btn) { btn.addEventListener("click", function () { - const lang = btn.dataset.lang === "zh" ? "zh-CN" : btn.dataset.lang; + const lang = normalizePreferredLanguage( + btn.dataset.lang === "zh" ? "zh-CN" : btn.dataset.lang, + ); localStorage.setItem("preferredLanguage", lang); applyLang(lang); }); @@ -12,13 +16,13 @@ document.addEventListener("DOMContentLoaded", function () { }); function applyLang(lang) { - document.documentElement.lang = - lang === "en" ? "en" : lang === "pinyin" ? "zh-CN" : lang; + const normalized = normalizePreferredLanguage(lang); + localStorage.setItem("preferredLanguage", normalized); + applyStandardDocumentLang(normalized); document.querySelectorAll(".language-btn").forEach(function (btn) { const matches = - (btn.dataset.lang === "zh" && - (lang === "zh-CN" || lang === "zh")) || - btn.dataset.lang === lang; + (btn.dataset.lang === "zh" && normalized === "zh-CN") || + btn.dataset.lang === normalized; btn.classList.toggle("active", matches); }); } diff --git a/js/script.js b/js/script.js index db6f699..1cc07b8 100644 --- a/js/script.js +++ b/js/script.js @@ -57,23 +57,19 @@ function initializeLanguage() { } function setLanguage(lang) { - // Store the preference - localStorage.setItem("preferredLanguage", lang); + const normalized = normalizePreferredLanguage(lang); - // Update HTML lang attribute - document.documentElement.lang = lang; + localStorage.setItem("preferredLanguage", normalized); + applyStandardDocumentLang(normalized); + selectedLanguage = normalized; - // Update language buttons const buttons = document.querySelectorAll(".language-btn"); buttons.forEach((btn) => { - if ( - (btn.dataset.lang === "zh" && lang === "zh-CN") || - (btn.dataset.lang === btn.dataset.lang && lang === btn.dataset.lang) - ) { - btn.classList.add("active"); - } else { - btn.classList.remove("active"); - } + const btnLang = btn.dataset.lang; + const isActive = + (btnLang === "zh" && normalized === "zh-CN") || + btnLang === normalized; + btn.classList.toggle("active", isActive); }); } @@ -96,7 +92,7 @@ function initializeLanguageButtons() { let currentDayPage = 1; const daysPerPage = 10; const totalDays = 40; -let selectedLanguage = localStorage.getItem("preferredLanguage") || "zh-CN"; // Default to Simplified Chinese +let selectedLanguage = "zh-CN"; function generateDayGrid() { const dayGrid = document.querySelector(".day-grid"); @@ -355,9 +351,18 @@ function updateProgress(day) { fill.style.width = `${progress}%`; }); - // Update all progress texts document.querySelectorAll(".progress-container p").forEach((text) => { - text.textContent = `Progress: Day ${completed}/40`; + const zh = text.querySelector(".zh"); + const en = text.querySelector(".en"); + if (zh) { + zh.textContent = `进度:第${completed}/40天`; + } + if (en) { + en.textContent = `Progress: Day ${completed}/40`; + } + if (!zh && !en) { + text.textContent = `Progress: Day ${completed}/40`; + } }); document.querySelectorAll(".day-grid a").forEach((link) => { diff --git a/js/script.min.js b/js/script.min.js index db6f699..1cc07b8 100644 --- a/js/script.min.js +++ b/js/script.min.js @@ -57,23 +57,19 @@ function initializeLanguage() { } function setLanguage(lang) { - // Store the preference - localStorage.setItem("preferredLanguage", lang); + const normalized = normalizePreferredLanguage(lang); - // Update HTML lang attribute - document.documentElement.lang = lang; + localStorage.setItem("preferredLanguage", normalized); + applyStandardDocumentLang(normalized); + selectedLanguage = normalized; - // Update language buttons const buttons = document.querySelectorAll(".language-btn"); buttons.forEach((btn) => { - if ( - (btn.dataset.lang === "zh" && lang === "zh-CN") || - (btn.dataset.lang === btn.dataset.lang && lang === btn.dataset.lang) - ) { - btn.classList.add("active"); - } else { - btn.classList.remove("active"); - } + const btnLang = btn.dataset.lang; + const isActive = + (btnLang === "zh" && normalized === "zh-CN") || + btnLang === normalized; + btn.classList.toggle("active", isActive); }); } @@ -96,7 +92,7 @@ function initializeLanguageButtons() { let currentDayPage = 1; const daysPerPage = 10; const totalDays = 40; -let selectedLanguage = localStorage.getItem("preferredLanguage") || "zh-CN"; // Default to Simplified Chinese +let selectedLanguage = "zh-CN"; function generateDayGrid() { const dayGrid = document.querySelector(".day-grid"); @@ -355,9 +351,18 @@ function updateProgress(day) { fill.style.width = `${progress}%`; }); - // Update all progress texts document.querySelectorAll(".progress-container p").forEach((text) => { - text.textContent = `Progress: Day ${completed}/40`; + const zh = text.querySelector(".zh"); + const en = text.querySelector(".en"); + if (zh) { + zh.textContent = `进度:第${completed}/40天`; + } + if (en) { + en.textContent = `Progress: Day ${completed}/40`; + } + if (!zh && !en) { + text.textContent = `Progress: Day ${completed}/40`; + } }); document.querySelectorAll(".day-grid a").forEach((link) => { diff --git a/tests/e2e/localization.spec.ts b/tests/e2e/localization.spec.ts index c8804cd..e59e2f7 100644 --- a/tests/e2e/localization.spec.ts +++ b/tests/e2e/localization.spec.ts @@ -1,6 +1,24 @@ import { test, expect } from "@playwright/test"; test.describe("page localization", () => { + test("index language card progress follows lang from URL", async ({ + page, + }) => { + await page.goto("/index.html?lang=zh", { waitUntil: "load" }); + await expect(page.locator("html")).toHaveAttribute("lang", "zh-CN"); + const zhCard = page.locator(".language-card").first(); + await expect(zhCard.locator(".progress-container .zh")).toBeVisible(); + await expect(zhCard.locator(".progress-container .en")).toBeHidden(); + await expect(zhCard.locator(".progress-container .zh")).toContainText( + "进度:第", + ); + + const stored = await page.evaluate(() => + localStorage.getItem("preferredLanguage"), + ); + expect(stored).toBe("zh-CN"); + }); + test("placement static and result UI follow lang", async ({ page }) => { await page.goto("/placement.html?lang=zh", { waitUntil: "load" }); await expect(page.locator("#placement-title .zh")).toHaveText(