├── data ├── examples │ ├── mcq-multi-answer.md │ ├── sort-into-boxes.md │ ├── fib-simple.md │ ├── mcq.md │ ├── fill-in-the-blanks.md │ ├── matching.md │ ├── fib-multiple.md │ ├── swipe-left-right.md │ ├── mcq-2-questions.md │ ├── fib-2.md │ ├── fib-with-long-values.md │ └── fib-1.md ├── question.md └── answer.md ├── .gitignore ├── .gitmodules ├── package.json ├── public ├── styles.css ├── components │ ├── toolbar.css │ └── toolbar.js ├── index.html ├── modules │ ├── sort.js │ ├── swipe.js │ ├── fib.css │ ├── mcq.css │ ├── matching.css │ ├── fib.js │ ├── matching.js │ └── mcq.js └── app.js ├── .github └── workflows │ └── build-release.yaml ├── README.md ├── LICENSE ├── PRD_MCQ.md └── server.js /data/examples/mcq-multi-answer.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | data/answer.md 3 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "public/design-system"] 2 | path = public/design-system 3 | url = https://github.com/CodeSignal/learn_bespoke-design-system.git 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cosmo-activities-web", 3 | "version": "1.0.0", 4 | "main": "server.js", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1", 7 | "start": "node server.js" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "description": "", 13 | "dependencies": { 14 | "marked": "^4.3.0", 15 | "ws": "^8.18.3" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /data/examples/sort-into-boxes.md: -------------------------------------------------------------------------------- 1 | __Type__ 2 | 3 | Sort Into Boxes 4 | 5 | __Practice Question__ 6 | 7 | Sort these people leadership behaviors into the correct dimension categories: 8 | 9 | __Labels__ 10 | 11 | - First Box Label: Directive Behavior 12 | - Second Box Label: Supportive Behavior 13 | 14 | __First Box Items__ 15 | 16 | - Setting expectations 17 | - Giving guidancey 18 | - Tracking progress 19 | 20 | __Second Box Items__ 21 | 22 | - Active listening 23 | - Recognizing wins 24 | - Offering assistance 25 | 26 | 27 | -------------------------------------------------------------------------------- /data/examples/fib-simple.md: -------------------------------------------------------------------------------- 1 | __Type__ 2 | 3 | Fill In The Blanks 4 | 5 | __Markdown With Blanks__ 6 | 7 | Fill in the blanks 8 | 9 | > This paragraph exists solely to serve as an [[blank:example]], which makes it both useful and completely [[blank:useless]] at the same time. Its main purpose is to pretend it has a purpose, while subtly teaching you that sometimes learning happens through [[blank:nonsense]] rather than deep meaning. 10 | 11 | 12 | __Suggested Answers__ 13 | 14 | - test 15 | - example 16 | - useless 17 | - nonsense 18 | -------------------------------------------------------------------------------- /data/examples/mcq.md: -------------------------------------------------------------------------------- 1 | __Type__ 2 | 3 | Multiple Choice 4 | 5 | __Practice Question__ 6 | 7 | Let's test your understanding! Maria is a freelance designer who wants to be known for "high-quality, timely work." She delivers beautiful designs but always misses deadlines. According to the brand triangle, what's her main problem? 8 | 9 | A. Her brand identity is wrong 10 | B. Her actual brand doesn't match her identity 11 | C. Her brand image is too positive 12 | D. She needs a better logo 13 | 14 | __Suggested Answers__ 15 | 16 | - A 17 | - B - Correct 18 | - C 19 | - D 20 | -------------------------------------------------------------------------------- /data/examples/fill-in-the-blanks.md: -------------------------------------------------------------------------------- 1 | __Type__ 2 | 3 | Fill In The Blanks 4 | 5 | __Markdown With Blanks__ 6 | 7 | Fill in the blanks 8 | 9 | > Lisa notices her team member Kevin always needs time to process decisions and gets anxious with quick turnarounds. Instead of continuing her usual [[blank:fast-moving]] approach, Lisa decides to provide more [[blank:advance]] notice and processing time. This shows she's acting like a [[blank:coach]] rather than just a [[blank:scorekeeper]]. 10 | 11 | __Suggested Answers__ 12 | 13 | - fast-moving 14 | - advance 15 | - coach 16 | - scorekeeper 17 | - manager 18 | 19 | 20 | -------------------------------------------------------------------------------- /data/examples/matching.md: -------------------------------------------------------------------------------- 1 | __Type__ 2 | 3 | Matching 4 | 5 | __Markdown With Blanks__ 6 | 7 | > **Subtle Cue 1**: Team members are avoiding eye contact and have closed body language. [[blank:Discomfort]] 8 | 9 | > **Subtle Cue 2**: Several people are nodding, smiling, and leaning forward during a discussion. [[blank:Engagement]] 10 | 11 | > **Subtle Cue 3**: Voices are raised and people are talking over each other. [[blank:Tension]] 12 | 13 | > **Subtle Cue 4**: Someone sighs and looks away when a topic comes up. [[blank:Frustration]] 14 | 15 | > **Subtle Cue 5**: Laughter and relaxed posture spread through the group after a team win. [[blank:Relief]] 16 | 17 | __Suggested Answers__ 18 | 19 | - Discomfort 20 | - Engagement 21 | - Tension 22 | - Frustration 23 | - Relief 24 | -------------------------------------------------------------------------------- /data/examples/fib-multiple.md: -------------------------------------------------------------------------------- 1 | __Type__ 2 | 3 | Fill In The Blanks 4 | 5 | __Markdown With Blanks__ 6 | 7 | Fill in the blanks 8 | 9 | > **Example 1**: Someone crosses their arms and avoids eye contact during a tough conversation. [[blank:Physical]] 10 | 11 | > **Example 2**: A person says, "You always do this!" when feeling upset. [[blank:Verbal]] 12 | 13 | > **Example 3**: You notice your heart beating faster and feel the urge to interrupt. [[blank:Emotional]] 14 | 15 | > **Example 4**: Someone starts raising their voice and brings up old arguments. [[blank:Verbal]] 16 | 17 | > **Example 5**: You clench your fists and your jaw feels tight. [[blank:Physical]] 18 | 19 | > **Example 6**: You catch yourself thinking, "That's not fair!" and planning your comeback instead of listening. [[blank:Emotional]] 20 | 21 | __Suggested Answers__ 22 | 23 | - Physical 24 | - Physical 25 | - Verbal 26 | - Verbal 27 | - Emotional 28 | - Emotional 29 | -------------------------------------------------------------------------------- /data/examples/swipe-left-right.md: -------------------------------------------------------------------------------- 1 | __Type__ 2 | 3 | Swipe Left or Right 4 | 5 | __Practice Question__ 6 | 7 | Let's test your understanding of adaptive vs. traditional management. Swipe each statement left if it represents adaptive leadership, or right if it represents traditional management. 8 | 9 | __Labels__ 10 | 11 | - Left Label: Adaptive Leadership 12 | - Right Label: Traditional Mgmt 13 | 14 | __Left Label Items__ 15 | 16 | - Adjusting communication style based on employee personality 17 | - Giving more project autonomy to experienced team members 18 | - Using different motivation strategies for different people 19 | - Adapting feedback delivery to individual preferences 20 | 21 | __Right Label Items__ 22 | 23 | - Using the same management approach with everyone 24 | - Expecting all employees to adapt to your style 25 | - Following rigid company policies regardless of situation 26 | - Treating all team members exactly the same way 27 | 28 | 29 | -------------------------------------------------------------------------------- /public/styles.css: -------------------------------------------------------------------------------- 1 | /* ===== ACTIVITY-SPECIFIC STYLES ===== */ 2 | /* This file contains only styles specific to the learning activities */ 3 | 4 | /* ===== PAGE LAYOUT ===== */ 5 | html, body { 6 | height: 100%; 7 | } 8 | 9 | body { 10 | display: flex; 11 | flex-direction: column; 12 | min-height: 100vh; 13 | background: var(--Colors-Backgrounds-Main-Default); 14 | } 15 | 16 | body:has(.matching) { 17 | margin: 0; 18 | } 19 | 20 | /* Main content area */ 21 | .main { 22 | flex: 1; 23 | display: flex; 24 | flex-direction: column; 25 | justify-content: center; 26 | padding: var(--UI-Spacing-spacing-mxl) var(--UI-Spacing-spacing-ms); 27 | max-width: 896px;; 28 | width: 100%; 29 | margin: 0 auto; 30 | } 31 | 32 | .main:has(.matching) { 33 | max-width: none; 34 | padding-left: 0; 35 | padding-right: 0; 36 | } 37 | 38 | /* Activity container */ 39 | .activity-container { 40 | display: grid; 41 | place-items: center; 42 | min-height: 400px;; 43 | } 44 | -------------------------------------------------------------------------------- /data/examples/mcq-2-questions.md: -------------------------------------------------------------------------------- 1 | __Type__ 2 | 3 | Multiple Choice 4 | 5 | __Practice Question__ 6 | 7 | Maria is a freelance designer who wants to be known for "high-quality, timely work." She delivers beautiful designs but always misses deadlines. According to the brand triangle, what's her main problem? 8 | 9 | A. Her brand identity is wrong 10 | B. Her actual brand doesn't match her identity 11 | C. Her brand image is too positive 12 | D. She needs a better logo 13 | 14 | __Suggested Answers__ 15 | 16 | - A 17 | - B - Correct 18 | - C 19 | - D 20 | 21 | __Practice Question__ 22 | 23 | Which of the following issues might be true for Alex, a freelance developer who wants to be known for producing "efficient, bug-free code delivered on time"? He is well-liked by clients for his speedy turnarounds, but his code is often buggy and hard for teammates to maintain. According to the brand triangle, what are his main problems? 24 | 25 | A. His actual brand doesn't match his intended identity 26 | B. He needs to improve his communication skills 27 | C. His brand image is inconsistent with his identity 28 | D. He needs a new logo 29 | 30 | __Suggested Answers__ 31 | 32 | - A - Correct 33 | - B 34 | - C - Correct 35 | - D 36 | 37 | -------------------------------------------------------------------------------- /.github/workflows/build-release.yaml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | build-and-release: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v4 17 | 18 | - name: Populate design system submodule 19 | run: git submodule update --init 20 | 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: '20' 25 | cache: 'npm' 26 | 27 | - name: Install dependencies 28 | run: npm ci 29 | 30 | - name: Archive build output 31 | run: tar -czf dist.tar.gz * 32 | 33 | - name: Upload build artifact (for workflow logs) 34 | uses: actions/upload-artifact@v4 35 | with: 36 | name: dist 37 | path: dist 38 | 39 | - name: Upload asset to existing release 40 | uses: ncipollo/release-action@v1 41 | with: 42 | token: ${{ secrets.GITHUB_TOKEN }} 43 | tag: ${{ github.event.release.tag_name }} 44 | artifacts: dist.tar.gz 45 | allowUpdates: true 46 | omitBodyDuringUpdate: true 47 | omitNameDuringUpdate: true 48 | -------------------------------------------------------------------------------- /data/examples/fib-2.md: -------------------------------------------------------------------------------- 1 | __Type__ 2 | 3 | Fill In The Blanks 4 | 5 | __Markdown With Blanks__ 6 | 7 | Match the abilities with their definitions 8 | 9 | > 1. [[blank:Self-Awareness]] is the ability to recognize and understand your own emotions as they occur, allowing you to accurately assess your strengths and weaknesses and maintain grounded confidence in your interactions. 10 | 11 | > 2. [[blank:Self-Regulation]] involves controlling disruptive impulses, staying calm under pressure, and adapting to changing circumstances, which helps you maintain consistency and integrity in your actions. 12 | 13 | > 3. [[blank:Motivation]] is the internal drive that keeps you pursuing goals with energy and persistence, even when faced with setbacks, and is fueled by your own standards of excellence rather than external rewards. 14 | 15 | > 4. [[blank:Empathy]] means moving beyond surface reactions to truly understand the feelings, concerns, and motivations of others, enabling you to respond thoughtfully and build stronger relationships. 16 | 17 | > 5. [[blank:Social Skills]] refers to the ability to manage relationships and build networks effectively, using communication and collaboration skills to unite diverse groups and achieve shared objectives. 18 | 19 | 20 | __Suggested Answers__ 21 | 22 | - Self-Awareness 23 | - Self-Regulation 24 | - Motivation 25 | - Empathy 26 | - Social Skills 27 | -------------------------------------------------------------------------------- /data/question.md: -------------------------------------------------------------------------------- 1 | __Type__ 2 | 3 | Matching 4 | 5 | __Markdown With Blanks__ 6 | 7 | Match each description to the decision-making style it describes. 8 | 9 | > This type gets energized by bold ideas and breakthrough thinking. They absorb information rapidly and want to see bottom-line results. Visual aids showing competitive advantage are essential when presenting to them. [[blank:Charismatic]] 10 | 11 | > This type enjoys intellectual challenges and takes pride in being methodical and precise. They genuinely enjoy processing comprehensive data and want to understand your methodology as much as your conclusions. [[blank:Thinker]] 12 | 13 | > This type is suspicious by nature and will aggressively challenge every data point looking for flaws. They are primarily influenced by the credibility of the presenter and need endorsements from people they trust. [[blank:Skeptic]] 14 | 15 | > This type makes decisions based on how similar initiatives have worked in the past. They want proven methods, trusted brands, and case studies from companies they admire rather than breakthrough innovations. [[blank:Follower]] 16 | 17 | > This type fears feeling out of control, especially regarding information. They want structured raw data they can personally verify and multiple scenarios to evaluate rather than polished conclusions. [[blank:Controller]] 18 | 19 | __Suggested Answers__ 20 | 21 | - Charismatic 22 | - Thinker 23 | - Skeptic 24 | - Follower 25 | - Controller 26 | -------------------------------------------------------------------------------- /data/examples/fib-with-long-values.md: -------------------------------------------------------------------------------- 1 | __Type__ 2 | 3 | Fill In The Blanks 4 | 5 | __Markdown With Blanks__ 6 | 7 | Fill in the blanks 8 | 9 | > **Situation 1:** A teammate says they're feeling stressed and overwhelmed. 10 | > **Best Response:** [[blank:That sounds tough. Do you want to talk more about it?]] 11 | 12 | > **Situation 2:** A teammate suggests a new way to do something that you hadn't considered. 13 | > **Best Response:** [[blank:I hadn’t thought of it that way. Can you tell me more?]] 14 | 15 | > **Situation 3:** You notice a teammate seems quieter than usual. 16 | > **Best Response:** [[blank:You seem a bit quiet today. Is everything okay?]] 17 | 18 | > **Situation 4:** A teammate helps you finish a report on time. 19 | > **Best Response:** [[blank:Thanks for helping me with that report! I really appreciated it.]] 20 | 21 | > **Situation 5:** You want to check in after a teammate mentioned an important event. 22 | > **Best Response:** [[blank:How did it go? How did it go? How did it go? How did it go? How did it go? How did it go? How did it go?]] 23 | 24 | __Suggested Answers__ 25 | 26 | - That sounds tough—do you want to talk more about it? 27 | - I hadn’t thought of it that way. Can you tell me more? 28 | - You seem a bit quiet today. Is everything okay? 29 | - Thanks for helping me with that report! I really appreciated it. 30 | - How did it go? How did it go? How did it go? How did it go? How did it go? How did it go? How did it go? 31 | -------------------------------------------------------------------------------- /public/components/toolbar.css: -------------------------------------------------------------------------------- 1 | /* ===== GLOBAL TOOLBAR STYLES ===== */ 2 | 3 | .global-toolbar { 4 | position: fixed; 5 | top: var(--UI-Spacing-spacing-ms, 16px); 6 | right: 48px; 7 | display: flex; 8 | gap: var(--UI-Spacing-spacing-xs, 8px); 9 | align-items: center; 10 | z-index: 1000; 11 | pointer-events: none; /* Allow clicks to pass through container */ 12 | } 13 | 14 | .global-toolbar-tool { 15 | display: flex; 16 | align-items: center; 17 | justify-content: center; 18 | width: 40px; 19 | height: 40px; 20 | padding: 0; 21 | border: none; 22 | border-radius: var(--UI-Radius-radius-s, 8px); 23 | background: transparent; 24 | color: var(--Colors-Text-Body-Default); 25 | cursor: pointer; 26 | pointer-events: auto; /* Re-enable clicks on buttons */ 27 | transition: all 0.2s ease; 28 | } 29 | 30 | .global-toolbar-tool:hover:not(:disabled) { 31 | background: transparent; 32 | opacity: 0.7; 33 | } 34 | 35 | .global-toolbar-tool:active:not(:disabled) { 36 | opacity: 0.5; 37 | } 38 | 39 | .global-toolbar-tool:disabled { 40 | opacity: 0.4; 41 | cursor: not-allowed; 42 | pointer-events: none; 43 | } 44 | 45 | .global-toolbar-tool .icon { 46 | width: 20px; 47 | height: 20px; 48 | color: currentColor; 49 | } 50 | 51 | @media (prefers-color-scheme: dark) { 52 | .global-toolbar-tool { 53 | background: transparent; 54 | } 55 | 56 | .global-toolbar-tool:hover:not(:disabled) { 57 | background: transparent; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /data/examples/fib-1.md: -------------------------------------------------------------------------------- 1 | __Type__ 2 | 3 | Fill In The Blanks 4 | 5 | __Markdown With Blanks__ 6 | 7 | Match the bosses with their style 8 | 9 | > **Boss 1**: You start your presentation with a bold vision and a simple chart showing the potential for rapid market growth. You avoid lengthy details and focus on the big win, using compelling visuals to capture attention. [[blank:Charismatic]] 10 | 11 | > **Boss 2**: You provide a detailed spreadsheet with raw data and multiple scenarios, allowing your boss to analyze the numbers independently. You avoid drawing conclusions for them and instead give them all the information they need to verify on their own. [[blank:Controller]] 12 | 13 | **Boss 3**: You open your pitch by referencing endorsements from respected colleagues and industry experts. You anticipate tough questions and are prepared to defend every data point, knowing your boss will challenge your credibility and sources. [[blank:Skeptic]] 14 | 15 | **Boss 4**: You organize your proposal with comprehensive research, case studies, and a clear explanation of your methodology. You give your boss time to process the information and invite them to discuss the logic behind your recommendations. [[blank:Thinker]] 16 | 17 | **Boss 5**: You highlight how similar companies have successfully implemented your idea, providing testimonials and references. You focus on proven results and established brands to reassure your boss that your proposal is a safe choice. [[blank:Follower]] 18 | 19 | __Suggested Answers__ 20 | 21 | - Thinker 22 | - Follower 23 | - Charismatic 24 | - Skeptic 25 | - Controller 26 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Activity 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 |
30 |
31 | 32 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /data/answer.md: -------------------------------------------------------------------------------- 1 | __Type__ 2 | 3 | Matching 4 | 5 | __Summary__ 6 | 7 | 0/5 correct 8 | 9 | __Responses__ 10 | 11 | 1. **Item 1** 12 | - Selected Answer: No answer selected 13 | - Correct Answer: Charismatic 14 | - Result: ✗ Incorrect 15 | 16 | 2. **Item 2** 17 | - Selected Answer: No answer selected 18 | - Correct Answer: Thinker 19 | - Result: ✗ Incorrect 20 | 21 | 3. **Item 3** 22 | - Selected Answer: No answer selected 23 | - Correct Answer: Skeptic 24 | - Result: ✗ Incorrect 25 | 26 | 4. **Item 4** 27 | - Selected Answer: No answer selected 28 | - Correct Answer: Follower 29 | - Result: ✗ Incorrect 30 | 31 | 5. **Item 5** 32 | - Selected Answer: Frustration 33 | - Correct Answer: Controller 34 | - Result: ✗ Incorrect 35 | 36 | __Markdown With Blanks__ 37 | 38 | Match each description to the decision-making style it describes. 39 | 40 | 41 | 42 | > This type gets energized by bold ideas and breakthrough thinking. They absorb information rapidly and want to see bottom-line results. Visual aids showing competitive advantage are essential when presenting to them. [[blank:Charismatic]] 43 | 44 | 45 | > This type enjoys intellectual challenges and takes pride in being methodical and precise. They genuinely enjoy processing comprehensive data and want to understand your methodology as much as your conclusions. [[blank:Thinker]] 46 | 47 | 48 | > This type is suspicious by nature and will aggressively challenge every data point looking for flaws. They are primarily influenced by the credibility of the presenter and need endorsements from people they trust. [[blank:Skeptic]] 49 | 50 | 51 | > This type makes decisions based on how similar initiatives have worked in the past. They want proven methods, trusted brands, and case studies from companies they admire rather than breakthrough innovations. [[blank:Follower]] 52 | 53 | 54 | > This type fears feeling out of control, especially regarding information. They want structured raw data they can personally verify and multiple scenarios to evaluate rather than polished conclusions. [[blank:Controller]] 55 | 56 | __Suggested Answers__ 57 | 58 | - Thinker 59 | - Follower 60 | - Skeptic 61 | - Controller 62 | - Charismatic 63 | 64 | -------------------------------------------------------------------------------- /public/modules/sort.js: -------------------------------------------------------------------------------- 1 | export function initSort({ items, labels, question, state, postResults }) { 2 | const elContainer = document.getElementById('activity-container'); 3 | 4 | // Use design system typography and spacing 5 | // Render a minimal placeholder view 6 | elContainer.innerHTML = ` 7 |
8 |

${question || 'Sort Activity'}

9 | 10 |
11 |

Categories

12 |
13 |
14 | ${labels.first || 'Box 1'} 15 |
16 |
17 | ${labels.second || 'Box 2'} 18 |
19 |
20 |
21 | 22 |
23 |

Items to Sort

24 | 31 |
32 |
33 | `; 34 | 35 | // No interactivity for now 36 | return () => { 37 | elContainer.innerHTML = ''; 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /public/modules/swipe.js: -------------------------------------------------------------------------------- 1 | export function initSwipe({ items, labels, question, state, postResults }) { 2 | const elContainer = document.getElementById('activity-container'); 3 | const leftLabelText = labels.left || 'Left'; 4 | const rightLabelText = labels.right || 'Right'; 5 | 6 | // Use design system typography and spacing 7 | // Render a minimal placeholder view 8 | elContainer.innerHTML = ` 9 |
10 |

11 | ${question || 'Swipe Activity'} 12 | (This activity is currently a placeholder) 13 |

14 | 15 |
16 |
17 |
Left Label
18 |
${leftLabelText}
19 |
20 |
21 |
Right Label
22 |
${rightLabelText}
23 |
24 |
25 | 26 |
27 |

Items to Swipe

28 | 35 |
36 |
37 | `; 38 | 39 | // No interactivity for now 40 | return { 41 | cleanup: () => { 42 | elContainer.innerHTML = ''; 43 | } 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cosmo Activities Web 2 | 3 | An interactive web-based learning platform that supports multiple types of educational activities. Intended to bring mobile activities to the web experience. Built with vanilla JavaScript and Node.js, this platform provides engaging ways to practice and assess knowledge through different interactive formats. 4 | 5 | ## Features 6 | 7 | ### Activity Types 8 | 9 | - **🎯 Swipe Left or Right**: Tinder-style interface for categorizing statements or concepts 10 | - **📝 Fill in the Blanks**: Interactive forms for completing educational content 11 | - **📦 Sort into Boxes**: Drag-and-drop interface for organizing items into categories 12 | 13 | ## Getting Started 14 | 15 | ### Installation 16 | 17 | 1. Clone the repository: 18 | ```bash 19 | git clone 20 | cd learn_cosmo-activities-web 21 | ``` 22 | 23 | 2. Install dependencies: 24 | ```bash 25 | npm install 26 | ``` 27 | 28 | 3. Start the server: 29 | ```bash 30 | npm start 31 | ``` 32 | 33 | 4. Open your browser and navigate to: 34 | ``` 35 | http://localhost:3000 36 | ``` 37 | 38 | ## Project Structure 39 | 40 | ``` 41 | learn_cosmo-activities-web/ 42 | ├── data/ # Activity content and results 43 | │ ├── question.md # Current activity definition 44 | │ ├── answer.md # Stored activity results 45 | │ └── examples/ # Example activity formats 46 | │ ├── fill-in-the-blanks.md 47 | │ ├── sort-into-boxes.md 48 | │ └── swipe-left-right.md 49 | ├── public/ # Frontend assets 50 | │ ├── index.html # Main HTML file 51 | │ ├── app.js # Main application logic 52 | │ ├── styles.css # Application styles 53 | │ └── modules/ # Activity-specific modules 54 | │ ├── fib.js # Fill-in-the-blanks functionality 55 | │ ├── sort.js # Sort-into-boxes functionality 56 | │ └── swipe.js # Swipe functionality 57 | ├── server.js # Node.js server 58 | ├── package.json # Dependencies and scripts 59 | └── README.md # This file 60 | ``` 61 | 62 | ## Creating Activities 63 | 64 | Activities are defined using Markdown files with a specific format. Place your activity definition in `data/question.md`. 65 | 66 | ## API Endpoints 67 | 68 | - `GET /api/activity` - Retrieves the current activity from `data/question.md` 69 | - `POST /api/results` - Saves activity results to `data/answer.md` 70 | 71 | ## Development 72 | 73 | The application uses vanilla JavaScript with ES6 modules. The server automatically serves files from the `public` directory and provides API endpoints for activity management. 74 | 75 | ### Key Components 76 | 77 | - **app.js**: Main application orchestrator 78 | - **modules/swipe.js**: Handles swipe-based interactions 79 | - **modules/fib.js**: Manages fill-in-the-blank activities 80 | - **modules/sort.js**: Implements sorting functionality 81 | - **server.js**: Express-like HTTP server with markdown parsing 82 | 83 | ## Dependencies 84 | 85 | - **marked**: Markdown parsing library for activity content 86 | -------------------------------------------------------------------------------- /public/components/toolbar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Global Toolbar Component 3 | * Provides a fixed toolbar in the upper-right corner for activity modules 4 | */ 5 | 6 | class Toolbar { 7 | constructor() { 8 | this.tools = new Map(); 9 | this.container = null; 10 | this.init(); 11 | } 12 | 13 | init() { 14 | // Find or create toolbar container 15 | let toolbar = document.getElementById('global-toolbar'); 16 | if (!toolbar) { 17 | toolbar = document.createElement('div'); 18 | toolbar.id = 'global-toolbar'; 19 | toolbar.className = 'global-toolbar'; 20 | document.body.appendChild(toolbar); 21 | } 22 | this.container = toolbar; 23 | } 24 | 25 | /** 26 | * Register a tool in the toolbar 27 | * @param {string} id - Unique identifier for the tool 28 | * @param {Object} options - Tool configuration 29 | * @param {string} options.icon - Icon class name (e.g., 'icon-eraser') 30 | * @param {string} options.title - Hover title/tooltip text 31 | * @param {Function} options.onClick - Callback function when tool is clicked 32 | * @param {boolean} options.enabled - Whether the tool is enabled (default: true) 33 | */ 34 | registerTool(id, options) { 35 | const { 36 | icon, 37 | title, 38 | onClick, 39 | enabled = true 40 | } = options; 41 | 42 | if (!icon || !title || !onClick) { 43 | console.error('Toolbar: registerTool requires icon, title, and onClick'); 44 | return; 45 | } 46 | 47 | // Remove existing tool if it exists 48 | this.unregisterTool(id); 49 | 50 | // Create tool button 51 | const toolButton = document.createElement('button'); 52 | toolButton.className = 'global-toolbar-tool'; 53 | toolButton.setAttribute('data-tool-id', id); 54 | toolButton.setAttribute('aria-label', title); 55 | toolButton.setAttribute('title', title); 56 | toolButton.disabled = !enabled; 57 | 58 | // Create icon element 59 | const iconEl = document.createElement('span'); 60 | iconEl.className = `icon ${icon}`; 61 | toolButton.appendChild(iconEl); 62 | 63 | // Add click handler 64 | toolButton.addEventListener('click', (e) => { 65 | e.preventDefault(); 66 | e.stopPropagation(); 67 | if (enabled && onClick) { 68 | onClick(e); 69 | } 70 | }); 71 | 72 | // Store tool reference 73 | this.tools.set(id, { 74 | element: toolButton, 75 | options: { icon, title, onClick, enabled } 76 | }); 77 | 78 | // Add to toolbar 79 | this.container.appendChild(toolButton); 80 | } 81 | 82 | /** 83 | * Unregister a tool from the toolbar 84 | * @param {string} id - Tool identifier 85 | */ 86 | unregisterTool(id) { 87 | const tool = this.tools.get(id); 88 | if (tool && tool.element && tool.element.parentNode) { 89 | tool.element.parentNode.removeChild(tool.element); 90 | } 91 | this.tools.delete(id); 92 | } 93 | 94 | /** 95 | * Update tool enabled state 96 | * @param {string} id - Tool identifier 97 | * @param {boolean} enabled - Whether tool should be enabled 98 | */ 99 | setToolEnabled(id, enabled) { 100 | const tool = this.tools.get(id); 101 | if (tool) { 102 | tool.element.disabled = !enabled; 103 | tool.options.enabled = enabled; 104 | } 105 | } 106 | 107 | /** 108 | * Clear all tools from the toolbar 109 | */ 110 | clear() { 111 | this.tools.forEach((tool, id) => { 112 | this.unregisterTool(id); 113 | }); 114 | } 115 | } 116 | 117 | // Create singleton instance 118 | const toolbar = new Toolbar(); 119 | 120 | export default toolbar; 121 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Elastic License 2.0 2 | 3 | URL: https://www.elastic.co/licensing/elastic-license 4 | 5 | ## Acceptance 6 | 7 | By using the software, you agree to all of the terms and conditions below. 8 | 9 | ## Copyright License 10 | 11 | The licensor grants you a non-exclusive, royalty-free, worldwide, 12 | non-sublicensable, non-transferable license to use, copy, distribute, make 13 | available, and prepare derivative works of the software, in each case subject to 14 | the limitations and conditions below. 15 | 16 | ## Limitations 17 | 18 | You may not provide the software to third parties as a hosted or managed 19 | service, where the service provides users with access to any substantial set of 20 | the features or functionality of the software. 21 | 22 | You may not move, change, disable, or circumvent the license key functionality 23 | in the software, and you may not remove or obscure any functionality in the 24 | software that is protected by the license key. 25 | 26 | You may not alter, remove, or obscure any licensing, copyright, or other notices 27 | of the licensor in the software. Any use of the licensor’s trademarks is subject 28 | to applicable law. 29 | 30 | ## Patents 31 | 32 | The licensor grants you a license, under any patent claims the licensor can 33 | license, or becomes able to license, to make, have made, use, sell, offer for 34 | sale, import and have imported the software, in each case subject to the 35 | limitations and conditions in this license. This license does not cover any 36 | patent claims that you cause to be infringed by modifications or additions to 37 | the software. If you or your company make any written claim that the software 38 | infringes or contributes to infringement of any patent, your patent license for 39 | the software granted under these terms ends immediately. If your company makes 40 | such a claim, your patent license ends immediately for work on behalf of your 41 | company. 42 | 43 | ## Notices 44 | 45 | You must ensure that anyone who gets a copy of any part of the software from you 46 | also gets a copy of these terms. 47 | 48 | If you modify the software, you must include in any modified copies of the 49 | software prominent notices stating that you have modified the software. 50 | 51 | ## No Other Rights 52 | 53 | These terms do not imply any licenses other than those expressly granted in 54 | these terms. 55 | 56 | ## Termination 57 | 58 | If you use the software in violation of these terms, such use is not licensed, 59 | and your licenses will automatically terminate. If the licensor provides you 60 | with a notice of your violation, and you cease all violation of this license no 61 | later than 30 days after you receive that notice, your licenses will be 62 | reinstated retroactively. However, if you violate these terms after such 63 | reinstatement, any additional violation of these terms will cause your licenses 64 | to terminate automatically and permanently. 65 | 66 | ## No Liability 67 | 68 | *As far as the law allows, the software comes as is, without any warranty or 69 | condition, and the licensor will not be liable to you for any damages arising 70 | out of these terms or the use or nature of the software, under any kind of 71 | legal claim.* 72 | 73 | ## Definitions 74 | 75 | The **licensor** is the entity offering these terms, and the **software** is the 76 | software the licensor makes available under these terms, including any portion 77 | of it. 78 | 79 | **you** refers to the individual or entity agreeing to these terms. 80 | 81 | **your company** is any legal entity, sole proprietorship, or other kind of 82 | organization that you work for, plus all organizations that have control over, 83 | are under the control of, or are under common control with that 84 | organization. **control** means ownership of substantially all the assets of an 85 | entity, or the power to direct its management and policies by vote, contract, or 86 | otherwise. Control can be direct or indirect. 87 | 88 | **your licenses** are all the licenses granted to you for the software under 89 | these terms. 90 | 91 | **use** means anything you do with the software requiring one of your licenses. 92 | 93 | **trademark** means trademarks, service marks, and similar rights. 94 | -------------------------------------------------------------------------------- /public/modules/fib.css: -------------------------------------------------------------------------------- 1 | /* ===== FILL IN THE BLANKS ACTIVITY STYLES ===== */ 2 | 3 | /* FIB-specific CSS Variables */ 4 | :root { 5 | --Colors-Learn-Practice-Blank-Slot-Border: var(--Colors-Stroke-Light); 6 | --Colors-Learn-Practice-Blank-Slot: var(--Colors-Base-Neutral-00); 7 | --Colors-Learn-Practice-Blank-Slot-Border-Hover: var(--Colors-Backgrounds-Main-Strong); 8 | --Colors-Learn-Practice-Blank-Slot-Hover: var(--Colors-Backgrounds-Main-Top); 9 | --Colors-Dropdown-Panel-Stroke: var(--Colors-Stroke-Default); 10 | --Colors-Dropdown-Panel: var(--Colors-Backgrounds-Main-Top); 11 | --Colors-Dropdown-Panel-Hover: var(--Colors-Base-Neutral-50); 12 | --Colors-Shadow-Float: rgba(22, 44, 96, 0.12); 13 | --Colors-Shadow-Medium-Soft: rgba(76, 90, 123, 0.08); 14 | --Colors-Menu-Tab-Link-Hover: var(--Colors-Text-Body-Default); 15 | --Colors-Menu-Option-Background-Hover: var(--Colors-Backgrounds-Main-Default); 16 | 17 | --Menu-Item-Height: 40px; 18 | --Menu-Item-Padding-Horizontal: 12px; 19 | --Menu-Item-Roundness: 8px; 20 | --Menu-Item-Font-Size: 16px; 21 | --Menu-Item-Line-Height: 24px; 22 | } 23 | 24 | @media (prefers-color-scheme: dark) { 25 | :root { 26 | --Colors-Learn-Practice-Blank-Slot-Border: var(--Colors-Backgrounds-Main-Light); 27 | --Colors-Learn-Practice-Blank-Slot: rgba(38, 49, 76, 0.30); 28 | --Colors-Learn-Practice-Blank-Slot-Border-Hover: var(--Colors-Backgrounds-Main-Medium); 29 | --Colors-Learn-Practice-Blank-Slot-Hover: rgba(38, 49, 76, 0.45); 30 | --Colors-Dropdown-Panel-Stroke: rgba(13, 21, 40, 0.30); 31 | --Colors-Dropdown-Panel-Hover: var(--Colors-Backgrounds-Main-Light); 32 | --Colors-Dropdown-Panel: var(--Colors-Backgrounds-Main-Medium); 33 | --Colors-Shadow-Float: rgba(13, 21, 40, 0.30); 34 | --Colors-Shadow-Medium-Soft: rgba(13, 21, 40, 0.40); 35 | --Colors-Menu-Tab-Link-Hover: var(--Colors-Base-Neutral-200); 36 | } 37 | } 38 | 39 | /* FIB Container */ 40 | .fib { 41 | display: grid; 42 | background: var(--Colors-Backgrounds-Main-Default); 43 | padding: var(--UI-Spacing-spacing-mxl); 44 | } 45 | 46 | .fib-header { 47 | display: flex; 48 | gap: 10px; 49 | align-items: flex-end; 50 | padding-bottom: 16px; 51 | padding-left: 0; 52 | padding-right: 0; 53 | padding-top: 0; 54 | margin-bottom: 0; 55 | } 56 | 57 | .fib-heading { 58 | flex: 1 0 0; 59 | color: var(--Colors-Text-Body-Strong); 60 | --webkit-font-smoothing: antialiased; 61 | margin: 0; 62 | padding: 0; 63 | min-height: 0; 64 | min-width: 0; 65 | position: relative; 66 | flex-shrink: 0; 67 | line-height: 1.35; 68 | } 69 | 70 | .fib-heading p { 71 | margin: 0; 72 | padding: 0; 73 | max-width: 585px; 74 | } 75 | 76 | .fib-actions { 77 | display: flex; 78 | gap: 6px; 79 | align-items: center; 80 | flex-shrink: 0; 81 | position: relative; 82 | } 83 | 84 | .fib-clear-all { 85 | display: flex; 86 | gap: 6px; 87 | align-items: center; 88 | padding: 0; 89 | border: none; 90 | background: transparent; 91 | color: var(--Colors-Text-Body-Light); 92 | text-align: center; 93 | text-decoration: none; 94 | font-weight: 600; /* override font-weight from .body-xxsmall */ 95 | cursor: pointer; 96 | white-space: nowrap; 97 | position: relative; 98 | flex-shrink: 0; 99 | } 100 | 101 | .fib-clear-all span { 102 | display: inline-block; 103 | } 104 | 105 | .fib-clear-all:hover { 106 | opacity: 0.8; 107 | } 108 | 109 | .fib-clear-all svg { 110 | flex-shrink: 0; 111 | width: 14px; 112 | height: 14px; 113 | vertical-align: middle; 114 | } 115 | 116 | .fib-content { 117 | background: transparent; 118 | border: none; 119 | color: var(--Colors-Text-Body-Default); 120 | width: 670px; 121 | line-height: 1.82; 122 | } 123 | 124 | .fib-content p { 125 | margin-top: 8px; 126 | color: var(--Colors-Text-Body-Default); 127 | } 128 | 129 | .blank { 130 | display: inline-flex; 131 | width: 96px; 132 | padding: 0 var(--UI-Spacing-spacing-s) 0 var(--UI-Spacing-spacing-s); 133 | align-items: center; 134 | gap: 10px; 135 | margin: 0 0.125rem; 136 | transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); 137 | box-sizing: border-box; 138 | vertical-align: middle; 139 | cursor: pointer; 140 | border-radius: var(--UI-Radius-radius-s); 141 | border: 1.5px solid var(--Colors-Learn-Practice-Blank-Slot-Border); 142 | background: var(--Colors-Learn-Practice-Blank-Slot); 143 | 144 | color: var(--Colors-Text-Body-Default); 145 | text-align: center; 146 | font-family: "Work Sans"; 147 | font-size: var(--Fonts-Body-Default-xxl); 148 | font-style: normal; 149 | font-weight: 400; 150 | line-height: 135%; /* 28.35px */ 151 | letter-spacing: -0.21px; 152 | 153 | width: auto; 154 | min-width: 0; 155 | min-height: 32px; 156 | } 157 | 158 | .blank.empty { 159 | width: 96px; 160 | } 161 | 162 | .blank:hover, 163 | .blank.dropdown-open { 164 | border-radius: var(--UI-Radius-radius-s); 165 | border: 1.5px solid var(--Colors-Learn-Practice-Blank-Slot-Border-Hover); 166 | box-shadow: 0 2px 3px 0 var(--Colors-Shadow-Medium-Soft); 167 | background-color: var(--Colors-Learn-Practice-Blank-Slot-Hover); 168 | } 169 | 170 | .fib-dropdown { 171 | position: absolute; 172 | z-index: 600; 173 | padding: var(--UI-Spacing-spacing-mxs); 174 | 175 | width: max-content; 176 | min-width: 220px; 177 | max-width: min(600px, calc(100vw - 40px)); 178 | 179 | display: flex; 180 | flex-direction: column; 181 | 182 | border-radius: var(--UI-Radius-radius-s); 183 | border: 1px solid var(--Colors-Dropdown-Panel-Stroke); 184 | background: var(--Colors-Dropdown-Panel); 185 | box-shadow: 0 12px 38px 0 var(--Colors-Shadow-Float); 186 | } 187 | 188 | .fib-option { 189 | border-radius: var(--UI-Radius-radius-xs); 190 | cursor: pointer; 191 | user-select: none; 192 | 193 | display: flex; 194 | min-height: var(--Menu-Item-Height); 195 | padding: var(--UI-Spacing-spacing-none) var(--Menu-Item-Padding-Horizontal); 196 | align-items: center; 197 | border-radius: var(--Menu-Item-Roundness); 198 | background: var(--Colors-Dropdown-Panel); 199 | white-space: normal; 200 | word-wrap: break-word; 201 | overflow-wrap: break-word; 202 | 203 | color: var(--Colors-Text-Body-Default); 204 | font-family: "Work Sans"; 205 | font-size: var(--Menu-Item-Font-Size); 206 | font-style: normal; 207 | font-weight: 400; 208 | line-height: var(--Menu-Item-Line-Height); 209 | letter-spacing: -0.24px; 210 | } 211 | 212 | .fib-option:hover { 213 | background: var(--Colors-Dropdown-Panel-Hover); 214 | color: var(--Colors-Menu-Tab-Link-Hover); 215 | font-weight: 500; 216 | } 217 | 218 | .fib-option-hover-measure { 219 | font-weight: 500; 220 | } 221 | -------------------------------------------------------------------------------- /public/modules/mcq.css: -------------------------------------------------------------------------------- 1 | /* ===== MULTIPLE CHOICE QUESTION ACTIVITY STYLES ===== */ 2 | 3 | /* MCQ-specific CSS Variables */ 4 | :root { 5 | --Colors-Learn-Practice-Card: var(--Colors-Backgrounds-Main-Top); 6 | --Colors-Learn-Practice-Card-Border-Hover: var(--Colors-Stroke-Medium); 7 | } 8 | 9 | @media (prefers-color-scheme: dark) { 10 | :root { 11 | --Colors-Learn-Practice-Card: var(--Colors-Backgrounds-Main-Light); 12 | --Colors-Learn-Practice-Card-Border-Hover: var(--Colors-Base-Neutral-1100); 13 | } 14 | } 15 | 16 | /* MCQ Container */ 17 | .mcq { 18 | display: flex; 19 | flex-direction: column; 20 | gap: var(--UI-Spacing-spacing-ms, 16px); 21 | padding: 1.5rem; 22 | max-width: 800px; 23 | margin: 0 auto; 24 | } 25 | 26 | .mcq-questions { 27 | display: flex; 28 | flex-direction: column; 29 | gap: 72px 30 | /* Padding will be added dynamically via JavaScript */ 31 | } 32 | 33 | .mcq-question { 34 | display: flex; 35 | flex-direction: column; 36 | gap: var(--UI-Spacing-spacing-ms, 16px); 37 | transition: border-color 0.2s ease, opacity 0.3s ease; 38 | } 39 | 40 | .mcq-question:not(.mcq-question-centered) { 41 | opacity: 0.3; 42 | } 43 | 44 | .mcq-question-incorrect { 45 | position: relative; 46 | border-radius: var(--UI-Radius-radius-ml, 16px); 47 | padding: var(--UI-Spacing-spacing-ms, 16px); 48 | padding-left: calc(var(--UI-Spacing-spacing-ms, 16px) + 20px); 49 | } 50 | 51 | .mcq-question-incorrect::before { 52 | content: ''; 53 | position: absolute; 54 | left: 0; 55 | top: 0; 56 | bottom: 0; 57 | width: 4px; 58 | background-color: var(--Colors-Alert-Error-Default); 59 | } 60 | 61 | .mcq-question-error-icon { 62 | position: absolute; 63 | left: -8px; 64 | top: 0; 65 | width: 20px; 66 | height: 20px; 67 | border-radius: 50%; 68 | background-color: var(--Colors-Alert-Error-Default); 69 | display: flex; 70 | align-items: center; 71 | justify-content: center; 72 | flex-shrink: 0; 73 | } 74 | 75 | .mcq-question-error-icon svg { 76 | width: 12px; 77 | height: 12px; 78 | fill: white; 79 | } 80 | 81 | .mcq-question-text { 82 | /* Typography from .body-xxlarge class */ 83 | /* font-family, font-size, font-style, font-weight, line-height, letter-spacing handled by .body-xlarge */ 84 | color: var(--Colors-Text-Body-Default); 85 | margin-bottom: var(--UI-Spacing-spacing-xs, 8px); 86 | line-height: 1.82; 87 | } 88 | 89 | .mcq-options { 90 | display: flex; 91 | flex-direction: column; 92 | gap: var(--UI-Spacing-spacing-mxs); 93 | border: none; 94 | padding: 0; 95 | margin: 0; 96 | } 97 | 98 | .mcq-legend { 99 | /* Typography from .heading-small class */ 100 | /* font-family, font-size, font-style, font-weight, line-height, letter-spacing handled by .heading-small */ 101 | color: var(--Colors-Text-Body-Strong); 102 | margin-bottom: var(--UI-Spacing-spacing-xs, 8px); 103 | padding: 0; 104 | line-height: 1.35; 105 | } 106 | 107 | .mcq-option { 108 | display: flex; 109 | padding: var(--UI-Spacing-spacing-ms, 16px) var(--UI-Spacing-spacing-mxl, 24px); 110 | padding-left: var(--UI-Spacing-spacing-ms, 16px); 111 | align-items: start; 112 | gap: var(--UI-Spacing-spacing-xs, 6px); 113 | align-self: stretch; 114 | border-radius: var(--UI-Radius-radius-ml); 115 | background: var(--Colors-Learn-Practice-Card); 116 | border: 2px solid transparent; 117 | color: var(--Colors-Text-Body-Default); 118 | font-family: "Work Sans", sans-serif; 119 | font-size: var(--Fonts-Body-Default-lg); 120 | font-style: normal; 121 | font-weight: 400; 122 | line-height: 130%; /* 22.1px */ 123 | letter-spacing: -0.17px; 124 | cursor: pointer; 125 | user-select: none; 126 | position: relative; 127 | } 128 | 129 | .mcq-option-card { 130 | display: flex; 131 | gap: var(--UI-Spacing-spacing-ml); 132 | align-items: center; 133 | flex: 1; 134 | } 135 | 136 | .mcq-option:hover { 137 | border-color: var(--Colors-Learn-Practice-Card-Border-Hover); 138 | background: var(--Colors-Learn-Practice-Card); 139 | } 140 | 141 | .mcq-option input:checked ~ .mcq-option-card, 142 | .mcq-option:has(input:checked) { 143 | border-color: var(--Colors-Learn-Practice-Card-Border-Hover); 144 | background: var(--Colors-Learn-Practice-Card); 145 | } 146 | 147 | /* Use design system checkbox and radio styles */ 148 | /* The input-checkbox and input-radio classes are imported from design-system/components/input/input.css */ 149 | /* We adapt the selectors to work with the MCQ nested structure */ 150 | 151 | /* Override design system selectors to work with nested structure */ 152 | .mcq-option.input-checkbox input[type="checkbox"]:checked ~ .mcq-option-card .input-checkbox-box, 153 | .mcq-option.input-radio input[type="radio"]:checked ~ .mcq-option-card .input-radio-circle { 154 | background: var(--Colors-Primary-Default); 155 | border-color: var(--Colors-Primary-Default); 156 | } 157 | 158 | .mcq-option.input-checkbox input[type="checkbox"]:checked ~ .mcq-option-card .input-checkbox-checkmark, 159 | .mcq-option.input-radio input[type="radio"]:checked ~ .mcq-option-card .input-radio-dot { 160 | display: block; 161 | } 162 | 163 | .mcq-option.input-checkbox:not(.disabled):hover .input-checkbox-box, 164 | .mcq-option.input-radio:not(.disabled):hover .input-radio-circle { 165 | border-color: var(--Colors-Stroke-Strong); 166 | } 167 | 168 | .mcq-option.input-checkbox input[type="checkbox"]:checked ~ .mcq-option-card .input-checkbox-box:hover, 169 | .mcq-option.input-radio input[type="radio"]:checked ~ .mcq-option-card .input-radio-circle:hover { 170 | border-color: var(--Colors-Primary-Default); 171 | } 172 | 173 | .mcq-option.input-checkbox input[type="checkbox"]:focus ~ .mcq-option-card .input-checkbox-box, 174 | .mcq-option.input-radio input[type="radio"]:focus ~ .mcq-option-card .input-radio-circle { 175 | outline: none; 176 | border-color: var(--Colors-Input-Border-Focus); 177 | } 178 | 179 | .mcq-option.input-checkbox input[type="checkbox"]:checked:focus ~ .mcq-option-card .input-checkbox-box, 180 | .mcq-option.input-radio input[type="radio"]:checked:focus ~ .mcq-option-card .input-radio-circle { 181 | border-color: var(--Colors-Primary-Default); 182 | } 183 | 184 | /* Ensure checkbox/radio elements are properly sized and positioned */ 185 | .mcq-option-card .input-checkbox-box, 186 | .mcq-option-card .input-radio-circle { 187 | flex-shrink: 0; 188 | margin: 0; 189 | } 190 | 191 | .mcq-option .input-checkbox-label, 192 | .mcq-option .input-radio-label { 193 | display: none; /* MCQ options don't use labels, text is in mcq-option-text */ 194 | } 195 | 196 | .mcq-option-content { 197 | flex: 1; 198 | display: flex; 199 | flex-direction: column; 200 | gap: var(--UI-Spacing-spacing-xl, 32px); 201 | min-width: 0; 202 | } 203 | 204 | .mcq-option-text { 205 | /* Typography from .body-large class */ 206 | /* font-family, font-size, font-style, font-weight, line-height, letter-spacing handled by .body-large */ 207 | color: var(--Colors-Text-Body-Default); 208 | white-space: pre-wrap; 209 | } 210 | 211 | .mcq-option-label { 212 | display: none; /* Option labels (A, B, C) are not shown in the design */ 213 | } 214 | 215 | .mcq-next-button-container { 216 | display: flex; 217 | justify-content: flex-start; 218 | margin-top: var(--UI-Spacing-spacing-ms, 16px); 219 | } 220 | 221 | .mcq-next-button { 222 | /* Button styles are handled by design system button classes */ 223 | } 224 | 225 | -------------------------------------------------------------------------------- /public/modules/matching.css: -------------------------------------------------------------------------------- 1 | /* ===== MATCHING ACTIVITY STYLES ===== */ 2 | 3 | /* Matching-specific CSS Variables */ 4 | :root { 5 | --Colors-Learn-Practice-Card: var(--Colors-Backgrounds-Main-Top); 6 | --Colors-Learn-Practice-Card-Border-Hover: var(--Colors-Stroke-Medium); 7 | --Colors-Learn-Practice-Selection-Area-Border: var(--Colors-Stroke-Default); 8 | --Colors-Learn-Practice-Selection-Area-Border-Active: var(--Colors-Primary-Default); 9 | --Colors-Learn-Practice-Selection-Area-Border-Matched: var(--Colors-Stroke-Default); 10 | --Colors-Learn-Practice-Selection-Area-Background: var(--Colors-Backgrounds-Main-Top); 11 | --Colors-Learn-Practice-Selection-Area-Placeholder: var(--Colors-Text-Body-Lighter); 12 | --Colors-Learn-Practice-Choice-Background-Used: var(--Colors-Backgrounds-Main-Light); 13 | --Colors-Learn-Practice-Choice-Label-Used: var(--Colors-Text-Body-Light); 14 | --Colors-Learn-Practice-Interactive-Choice-Main-Background: var(--Colors-Base-Accent-Sky-Blue-100); 15 | --Colors-Learn-Practice-Interactive-Choice-Main-Label: var(--Colors-Base-Accent-Sky-Blue-900); 16 | --Colors-Learn-Practice-Interactive-Choice-Main-Background-Hover: var(--Colors-Base-Accent-Sky-Blue-200); 17 | } 18 | 19 | @media (prefers-color-scheme: dark) { 20 | :root { 21 | --Colors-Learn-Practice-Card: var(--Colors-Backgrounds-Main-Light); 22 | --Colors-Learn-Practice-Card-Border-Hover: var(--Colors-Base-Neutral-1100); 23 | --Colors-Learn-Practice-Selection-Area-Border: var(--Colors-Backgrounds-Main-Light); 24 | --Colors-Learn-Practice-Selection-Area-Border-Active: var(--Colors-Primary-Default); 25 | --Colors-Learn-Practice-Selection-Area-Border-Matched: var(--Colors-Stroke-Default); 26 | --Colors-Learn-Practice-Selection-Area-Background: var(--Colors-Backgrounds-Main-Light); 27 | --Colors-Learn-Practice-Selection-Area-Placeholder: var(--Colors-Text-Body-Light); 28 | --Colors-Learn-Practice-Choice-Background-Used: var(--Colors-Backgrounds-Main-Medium); 29 | --Colors-Learn-Practice-Choice-Label-Used: var(--Colors-Text-Body-Medium); 30 | --Colors-Learn-Practice-Interactive-Choice-Main-Background: var(--Colors-Base-Accent-Sky-Blue-700); 31 | --Colors-Learn-Practice-Interactive-Choice-Main-Label: var(--Colors-Base-Neutral-00); 32 | --Colors-Learn-Practice-Interactive-Choice-Main-Background-Hover: var(--Colors-Base-Accent-Sky-Blue-600); 33 | } 34 | } 35 | 36 | /* Matching Container */ 37 | .matching { 38 | display: flex; 39 | flex-direction: column; 40 | background: var(--Colors-Backgrounds-Main-Default); 41 | padding: var(--UI-Spacing-spacing-mxl); 42 | min-height: calc(100vh - 120px); 43 | gap: var(--UI-Spacing-spacing-xl, 32px); 44 | max-width: 100%; 45 | overflow-x: hidden; 46 | } 47 | 48 | .matching-actions { 49 | display: flex; 50 | gap: 6px; 51 | align-items: center; 52 | flex-shrink: 0; 53 | position: relative; 54 | } 55 | 56 | .matching-clear-all { 57 | display: flex; 58 | gap: 6px; 59 | align-items: center; 60 | padding: 0; 61 | border: none; 62 | background: transparent; 63 | color: var(--Colors-Text-Body-Light); 64 | text-align: center; 65 | text-decoration: none; 66 | font-weight: 600; 67 | cursor: pointer; 68 | white-space: nowrap; 69 | position: relative; 70 | flex-shrink: 0; 71 | } 72 | 73 | .matching-clear-all span { 74 | display: inline-block; 75 | } 76 | 77 | .matching-clear-all:hover { 78 | opacity: 0.8; 79 | } 80 | 81 | .matching-clear-all svg { 82 | flex-shrink: 0; 83 | width: 14px; 84 | height: 14px; 85 | vertical-align: middle; 86 | } 87 | 88 | /* Matching Cards Container - uses horizontal-cards component */ 89 | .matching-cards-container { 90 | width: 100%; 91 | margin-bottom: var(--UI-Spacing-spacing-xl, 32px); 92 | } 93 | 94 | /* Instructions */ 95 | .matching-instructions { 96 | display: flex; 97 | flex-direction: column; 98 | align-items: center; 99 | gap: var(--UI-Spacing-spacing-s, 8px); 100 | margin-top: var(--UI-Spacing-spacing-mxs); 101 | margin-bottom: var(--UI-Spacing-spacing-none); 102 | } 103 | 104 | .matching-instructions-text { 105 | font-family: var(--body-family); 106 | font-size: var(--Fonts-Body-Default-xxs, 13px); 107 | font-weight: 400; 108 | line-height: 1.35; 109 | letter-spacing: -0.13px; 110 | color: var(--Colors-Text-Body-Light); 111 | text-align: center; 112 | margin: 0; 113 | } 114 | 115 | .horizontal-cards-card { 116 | width: 390px !important; 117 | min-width: 390px !important; 118 | } 119 | 120 | .horizontal-cards-card-action { 121 | min-height: 33px !important; 122 | height: 33px !important; 123 | } 124 | 125 | /* Selection area within horizontal-cards action area */ 126 | .horizontal-cards-card-action .matching-selection-area { 127 | width: 100%; 128 | margin: 0; 129 | } 130 | 131 | /* When matched, remove the dashed border container styling and make button full width */ 132 | .horizontal-cards-card-action .matching-selection-area.matched { 133 | margin: calc(-1 * var(--UI-Spacing-spacing-s, 8px)); 134 | width: calc(100% + 2 * var(--UI-Spacing-spacing-s, 8px)); 135 | border-radius: var(--UI-Radius-radius-xs, 6px); 136 | } 137 | 138 | /* Remove dashed border from action container when it contains a matched selection */ 139 | /* Keep padding to maintain vertical position consistency */ 140 | .horizontal-cards-card-action:has(.matching-selection-area.matched) { 141 | border: none; 142 | background: transparent; 143 | /* Keep same padding as empty state to prevent vertical jump */ 144 | padding: var(--UI-Spacing-spacing-s, 8px); 145 | } 146 | 147 | /* Selection Area */ 148 | .matching-selection-area { 149 | display: flex; 150 | align-items: center; 151 | justify-content: center; 152 | width: 100%; 153 | min-height: 33px; 154 | padding: var(--UI-Spacing-spacing-s, 8px) var(--UI-Spacing-spacing-ml, 20px); 155 | border-radius: var(--UI-Radius-radius-s, 8px); 156 | cursor: pointer; 157 | transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); 158 | text-align: center; 159 | box-sizing: border-box; 160 | } 161 | 162 | .matching-selection-area:not(.matched):not(.empty) { 163 | border: 1.5px dashed var(--Colors-Learn-Practice-Selection-Area-Border); 164 | background: var(--Colors-Learn-Practice-Selection-Area-Background); 165 | color: var(--Colors-Text-Body-Default); 166 | } 167 | 168 | .matching-selection-area.empty { 169 | /* Uses .body-xxsmall class from design system */ 170 | color: var(--Colors-Text-Body-Lighter); 171 | background: transparent; 172 | } 173 | 174 | .matching-selection-area.matched { 175 | /* Uses .button.button-primary.body-large classes from design system */ 176 | background: var(--Colors-Learn-Practice-Interactive-Choice-Main-Background); 177 | color: var(--Colors-Learn-Practice-Interactive-Choice-Main-Label); 178 | border: none; 179 | cursor: pointer; 180 | /* Override base padding to use button padding, but maintain same min-height */ 181 | padding: var(--UI-Spacing-spacing-ms, 16px) var(--UI-Spacing-spacing-xl, 32px); 182 | min-height: 48px; 183 | } 184 | 185 | .matching-selection-area.matched:hover { 186 | background: var(--Colors-Learn-Practice-Interactive-Choice-Main-Background-Hover); 187 | } 188 | 189 | .matching-selection-area:hover { 190 | border-color: var(--Colors-Learn-Practice-Selection-Area-Border-Active); 191 | 192 | } 193 | 194 | .matching-selection-area:focus { 195 | outline: none; 196 | border-color: var(--Colors-Learn-Practice-Selection-Area-Border-Active); 197 | box-shadow: 0 0 0 4px var(--Colors-Box-Focus-Shadow); 198 | } 199 | 200 | /* Active card styling - when a card is active, highlight its selection area */ 201 | .horizontal-cards-card-active .matching-selection-area { 202 | border-color: var(--Colors-Learn-Practice-Selection-Area-Border-Active); 203 | border-width: 2px; 204 | } 205 | 206 | /* Answer Choices List */ 207 | .matching-choices { 208 | display: flex; 209 | flex-wrap: wrap; 210 | gap: var(--UI-Spacing-spacing-mxs, 12px); 211 | padding: var(--UI-Spacing-spacing-mxl, 24px); 212 | padding-top: 0; 213 | position: sticky; 214 | bottom: 0; 215 | z-index: 100; 216 | justify-content: center; 217 | } 218 | 219 | .matching-choice-button { 220 | background: var(--Colors-Learn-Practice-Interactive-Choice-Main-Background); 221 | color: var(--Colors-Learn-Practice-Interactive-Choice-Main-Label); 222 | 223 | position: relative; 224 | } 225 | 226 | .matching-choice-button:hover { 227 | background: var(--Colors-Learn-Practice-Interactive-Choice-Main-Background-Hover); 228 | } 229 | 230 | .matching-choice-button:disabled, 231 | .matching-choice-button.used { 232 | background: var(--Colors-Learn-Practice-Choice-Background-Used); 233 | color: var(--Colors-Learn-Practice-Choice-Label-Used); 234 | cursor: not-allowed; 235 | opacity: 0.5; 236 | } 237 | 238 | -------------------------------------------------------------------------------- /public/modules/fib.js: -------------------------------------------------------------------------------- 1 | import toolbar from '../components/toolbar.js'; 2 | 3 | export function initFib({ activity, state, postResults, persistedAnswers = null }) { 4 | const elContainer = document.getElementById('activity-container'); 5 | const fib = activity.fib; 6 | 7 | // Create the fib container 8 | elContainer.innerHTML = ` 9 |
10 |
11 |

12 |
13 |
14 |
15 | `; 16 | 17 | const elFib = document.getElementById('fib'); 18 | const elFibHeading = elFib.querySelector('.fib-heading'); 19 | const elFibContent = document.getElementById('fib-content'); 20 | 21 | // Set static heading text 22 | if (elFibHeading) { 23 | elFibHeading.textContent = 'Fill in the blanks'; 24 | } 25 | 26 | // Content HTML is provided by server with embedded blank spans 27 | elFibContent.innerHTML = fib.htmlWithPlaceholders; 28 | 29 | // Build dropdowns in each blank and synchronize options across all blanks 30 | const blanks = Array.from(elFibContent.querySelectorAll('.blank')).sort((a, b) => { 31 | const aIdx = parseInt(a.getAttribute('data-blank') || '0', 10); 32 | const bIdx = parseInt(b.getAttribute('data-blank') || '0', 10); 33 | return aIdx - bIdx; 34 | }); 35 | 36 | // Selection state - initialize with persisted answers if available 37 | const selectedByBlankIdx = blanks.map((_, idx) => { 38 | if (persistedAnswers && persistedAnswers[idx] !== undefined) { 39 | return persistedAnswers[idx]; 40 | } 41 | return ''; 42 | }); 43 | let openDropdown = null; // { container, blank, idx } 44 | const totalCounts = new Map(); 45 | fib.choices.forEach(c => totalCounts.set(c, (totalCounts.get(c) || 0) + 1)); 46 | 47 | // Prepare blanks to be clickable triggers 48 | blanks.forEach((blank) => { 49 | const idx = parseInt(blank.getAttribute('data-blank') || '0', 10); 50 | blank.setAttribute('role', 'button'); 51 | blank.setAttribute('aria-haspopup', 'listbox'); 52 | blank.setAttribute('aria-expanded', 'false'); 53 | blank.tabIndex = 0; 54 | blank.textContent = '...'; 55 | blank.addEventListener('click', () => { 56 | if (selectedByBlankIdx[idx]) { 57 | setSelection(idx, ''); 58 | return; 59 | } 60 | openMenuForBlank(blank, idx); 61 | }); 62 | blank.addEventListener('keydown', (e) => { 63 | if (e.key === 'Enter' || e.key === ' ') { 64 | e.preventDefault(); 65 | if (selectedByBlankIdx[idx]) { 66 | setSelection(idx, ''); 67 | } else { 68 | openMenuForBlank(blank, idx); 69 | } 70 | } 71 | }); 72 | }); 73 | 74 | function getUsedCounts(exceptIdx) { 75 | const used = new Map(); 76 | selectedByBlankIdx.forEach((val, i) => { 77 | if (val && i !== exceptIdx) { 78 | used.set(val, (used.get(val) || 0) + 1); 79 | } 80 | }); 81 | return used; 82 | } 83 | 84 | function updateBlankDisplays() { 85 | blanks.forEach((blank, i) => { 86 | const value = selectedByBlankIdx[i]; 87 | blank.textContent = value || ''; 88 | blank.classList.toggle('empty', !value); 89 | }); 90 | } 91 | 92 | function closeMenu() { 93 | if (!openDropdown) return; 94 | const { container, blank } = openDropdown; 95 | if (container && container.parentNode) container.parentNode.removeChild(container); 96 | blank.setAttribute('aria-expanded', 'false'); 97 | blank.classList.remove('dropdown-open'); 98 | document.removeEventListener('mousedown', handleOutside); 99 | window.removeEventListener('resize', closeMenu); 100 | window.removeEventListener('scroll', closeMenu, true); 101 | openDropdown = null; 102 | } 103 | 104 | function handleOutside(e) { 105 | if (!openDropdown) return; 106 | const { container, blank } = openDropdown; 107 | // Keep dropdown open if clicking inside dropdown or blank 108 | if (container.contains(e.target) || blank.contains(e.target)) return; 109 | closeMenu(); 110 | } 111 | 112 | function handleDropdownMouseEnter() { 113 | if (!openDropdown) return; 114 | // Ensure hover state is maintained when mouse enters dropdown 115 | openDropdown.blank.classList.add('dropdown-open'); 116 | } 117 | 118 | function handleDropdownMouseLeave() { 119 | if (!openDropdown) return; 120 | // Don't remove the class immediately - let closeMenu handle it 121 | // This prevents flickering when moving between blank and dropdown 122 | } 123 | 124 | function openMenuForBlank(blank, idx) { 125 | if (openDropdown && openDropdown.blank === blank) { closeMenu(); return; } 126 | closeMenu(); 127 | const used = getUsedCounts(idx); 128 | const current = selectedByBlankIdx[idx]; 129 | 130 | const rect = blank.getBoundingClientRect(); 131 | 132 | // Build available list preserving duplicates but respecting remaining counts 133 | const availCounts = new Map(); 134 | totalCounts.forEach((total, key) => { 135 | const usedCount = used.get(key) || 0; 136 | availCounts.set(key, Math.max(0, total - usedCount)); 137 | }); 138 | const available = []; 139 | fib.choices.forEach(choice => { 140 | const left = availCounts.get(choice) || 0; 141 | if (left > 0) { 142 | available.push(choice); 143 | availCounts.set(choice, left - 1); 144 | } 145 | }); 146 | 147 | const menu = document.createElement('div'); 148 | menu.className = 'fib-dropdown'; 149 | menu.setAttribute('role', 'listbox'); 150 | menu.style.position = 'absolute'; 151 | menu.style.minWidth = Math.max(rect.width, 220) + 'px'; 152 | 153 | const docX = rect.left + window.scrollX; 154 | const docY = rect.bottom + window.scrollY + 4; 155 | menu.style.left = docX + 'px'; 156 | menu.style.top = docY + 'px'; 157 | 158 | // Add class to blank to keep hover effect active 159 | blank.classList.add('dropdown-open'); 160 | 161 | available.forEach(choice => { 162 | const opt = document.createElement('div'); 163 | opt.className = 'fib-option'; 164 | opt.setAttribute('role', 'option'); 165 | opt.textContent = choice; 166 | 167 | if (choice === current) { 168 | opt.setAttribute('aria-selected', 'true'); 169 | opt.classList.add('selected'); 170 | } 171 | opt.addEventListener('mousedown', (e) => { 172 | e.preventDefault(); 173 | setSelection(idx, choice); 174 | closeMenu(); 175 | }); 176 | menu.appendChild(opt); 177 | }); 178 | 179 | document.body.appendChild(menu); 180 | blank.setAttribute('aria-expanded', 'true'); 181 | openDropdown = { container: menu, blank, idx }; 182 | 183 | // Add mouse event listeners to maintain hover state 184 | menu.addEventListener('mouseenter', handleDropdownMouseEnter); 185 | menu.addEventListener('mouseleave', handleDropdownMouseLeave); 186 | blank.addEventListener('mouseenter', () => { 187 | if (openDropdown && openDropdown.blank === blank) { 188 | blank.classList.add('dropdown-open'); 189 | } 190 | }); 191 | 192 | document.addEventListener('mousedown', handleOutside); 193 | window.addEventListener('resize', closeMenu); 194 | window.addEventListener('scroll', closeMenu, true); 195 | } 196 | 197 | function setSelection(idx, choice) { 198 | selectedByBlankIdx[idx] = choice; 199 | updateBlankDisplays(); 200 | updateResultsAndPost(); 201 | } 202 | 203 | function updateResultsAndPost() { 204 | const total = selectedByBlankIdx.length; 205 | state.results = []; 206 | blanks.forEach((_, i) => { 207 | const idx = i; 208 | const correctAnswer = fib.blanks.find(x => x.index === idx)?.answer || ''; 209 | const chosen = selectedByBlankIdx[i] || ''; 210 | state.results.push({ 211 | text: `Blank ${i+1}`, 212 | selected: chosen, 213 | correct: correctAnswer 214 | }); 215 | }); 216 | state.index = selectedByBlankIdx.reduce((acc, v) => acc + (v ? 1 : 0), 0); 217 | postResults(); 218 | } 219 | 220 | updateBlankDisplays(); 221 | 222 | // Sync state with persisted answers if they exist 223 | if (persistedAnswers) { 224 | updateResultsAndPost(); 225 | } 226 | 227 | // Register "Clear All" tool in global toolbar 228 | toolbar.registerTool('fib-clear-all', { 229 | icon: 'icon-eraser', 230 | title: 'Clear All', 231 | onClick: (e) => { 232 | e.preventDefault(); 233 | // Clear all selections 234 | blanks.forEach((_, idx) => { 235 | selectedByBlankIdx[idx] = ''; 236 | }); 237 | updateBlankDisplays(); 238 | updateResultsAndPost(); // Persist the cleared state 239 | }, 240 | enabled: true 241 | }); 242 | 243 | return () => { 244 | closeMenu(); 245 | toolbar.unregisterTool('fib-clear-all'); 246 | elContainer.innerHTML = ''; // Remove the dynamically created fib container 247 | }; 248 | } 249 | -------------------------------------------------------------------------------- /public/app.js: -------------------------------------------------------------------------------- 1 | import { initSwipe } from './modules/swipe.js'; 2 | import { initSort } from './modules/sort.js'; 3 | import { initFib } from './modules/fib.js'; 4 | import { initMcq } from './modules/mcq.js'; 5 | import { initMatching } from './modules/matching.js'; 6 | import toolbar from './components/toolbar.js'; 7 | 8 | (() => { 9 | 'use strict'; 10 | 11 | // Shared DOM references only 12 | 13 | const state = { 14 | items: [], 15 | index: 0, 16 | results: [], 17 | }; 18 | 19 | let currentActivity = null; 20 | let currentActivityData = null; 21 | let socket = null; 22 | let validationHandler = null; 23 | 24 | async function postResults() { 25 | try { 26 | const response = await fetch('/api/results', { 27 | method: 'POST', 28 | headers: { 29 | 'Content-Type': 'application/json', 30 | }, 31 | body: JSON.stringify({ 32 | results: state.results, 33 | activity: currentActivityData, 34 | completedAt: new Date().toISOString() 35 | }) 36 | }); 37 | if (!response.ok) { 38 | throw new Error('Failed to save results'); 39 | } 40 | } catch (error) { 41 | console.error('Error saving results:', error); 42 | } 43 | } 44 | 45 | function reset() { 46 | state.index = 0; 47 | state.results = []; 48 | validationHandler = null; 49 | // Clear toolbar when resetting 50 | toolbar.clear(); 51 | if (currentActivity) { 52 | if (typeof currentActivity === 'function') { 53 | currentActivity(); // Old cleanup function style 54 | } else if (currentActivity.cleanup) { 55 | currentActivity.cleanup(); // New object style 56 | } 57 | currentActivity = null; 58 | } 59 | 60 | } 61 | 62 | async function loadActivityJson() { 63 | const url = '/api/activity'; 64 | const res = await fetch(url, { cache: 'no-store' }); 65 | if (!res.ok) throw new Error(`Failed to load ${url}: ${res.status}`); 66 | return await res.json(); 67 | } 68 | 69 | async function loadAnswers() { 70 | try { 71 | const url = '/api/answers'; 72 | const res = await fetch(url, { cache: 'no-store' }); 73 | if (!res.ok) throw new Error(`Failed to load ${url}: ${res.status}`); 74 | const data = await res.json(); 75 | return { answers: data.answers || null, type: data.type || null }; 76 | } catch (error) { 77 | console.error('Error loading answers:', error); 78 | return { answers: null, type: null }; 79 | } 80 | } 81 | 82 | function validatePersistedAnswers(activity, persistedData) { 83 | if (!persistedData || !persistedData.answers || !persistedData.type) { 84 | return null; 85 | } 86 | 87 | // Check that types match 88 | const currentType = activity.type || ''; 89 | const persistedType = persistedData.type || ''; 90 | 91 | if (!/^multiple choice$/i.test(currentType) && !/^fill in the blanks$/i.test(currentType) && !/^matching$/i.test(currentType)) { 92 | // Only validate MCQ, FIB, and Matching for now 93 | return null; 94 | } 95 | 96 | // Type must match exactly 97 | const typeMatches = 98 | (/^multiple choice$/i.test(currentType) && /^multiple choice$/i.test(persistedType)) || 99 | (/^fill in the blanks$/i.test(currentType) && /^fill in the blanks$/i.test(persistedType)) || 100 | (/^matching$/i.test(currentType) && /^matching$/i.test(persistedType)); 101 | 102 | if (!typeMatches) { 103 | return null; 104 | } 105 | 106 | // Validate structure matches 107 | if (/^multiple choice$/i.test(currentType)) { 108 | // For MCQ: validate that question IDs exist and match 109 | if (!activity.mcq || !activity.mcq.questions) { 110 | return null; 111 | } 112 | const validQuestionIds = new Set(activity.mcq.questions.map(q => q.id)); 113 | const persistedQuestionIds = Object.keys(persistedData.answers).map(id => parseInt(id, 10)); 114 | 115 | // All persisted question IDs must exist in current questions 116 | const allIdsValid = persistedQuestionIds.every(id => validQuestionIds.has(id)); 117 | if (!allIdsValid) { 118 | return null; 119 | } 120 | 121 | // Return only answers for valid question IDs 122 | const validatedAnswers = {}; 123 | persistedQuestionIds.forEach(id => { 124 | if (validQuestionIds.has(id)) { 125 | validatedAnswers[id] = persistedData.answers[id]; 126 | } 127 | }); 128 | return validatedAnswers; 129 | } else if (/^fill in the blanks$/i.test(currentType)) { 130 | // For FIB: validate that blank indices exist 131 | if (!activity.fib || !activity.fib.blanks) { 132 | return null; 133 | } 134 | const validBlankIndices = new Set(activity.fib.blanks.map(b => b.index)); 135 | const persistedBlankIndices = Object.keys(persistedData.answers).map(idx => parseInt(idx, 10)); 136 | 137 | // All persisted blank indices must exist in current blanks 138 | const allIndicesValid = persistedBlankIndices.every(idx => validBlankIndices.has(idx)); 139 | if (!allIndicesValid) { 140 | return null; 141 | } 142 | 143 | // Return only answers for valid blank indices 144 | const validatedAnswers = {}; 145 | persistedBlankIndices.forEach(idx => { 146 | if (validBlankIndices.has(idx)) { 147 | validatedAnswers[idx] = persistedData.answers[idx]; 148 | } 149 | }); 150 | return validatedAnswers; 151 | } else if (/^matching$/i.test(currentType)) { 152 | // For Matching: validate that item indices exist 153 | if (!activity.matching || !activity.matching.items) { 154 | return null; 155 | } 156 | const validItemIndices = new Set(activity.matching.items.map((item, idx) => idx)); 157 | const persistedItemIndices = Object.keys(persistedData.answers).map(idx => parseInt(idx, 10)); 158 | 159 | // All persisted item indices must exist in current items 160 | const allIndicesValid = persistedItemIndices.every(idx => validItemIndices.has(idx)); 161 | if (!allIndicesValid) { 162 | return null; 163 | } 164 | 165 | // Return only answers for valid item indices 166 | const validatedAnswers = {}; 167 | persistedItemIndices.forEach(idx => { 168 | if (validItemIndices.has(idx)) { 169 | validatedAnswers[idx] = persistedData.answers[idx]; 170 | } 171 | }); 172 | return validatedAnswers; 173 | } 174 | 175 | return null; 176 | } 177 | 178 | function initActivity(activity, persistedAnswers = null) { 179 | if (/^fill in the blanks$/i.test(activity.type)) { 180 | currentActivity = initFib({ activity, state, postResults, persistedAnswers }); 181 | } else if (/^sort into boxes$/i.test(activity.type)) { 182 | currentActivity = initSort({ 183 | items: state.items, 184 | labels: activity.labels, 185 | question: activity.question, 186 | state, 187 | postResults 188 | }); 189 | } else if (/^multiple choice$/i.test(activity.type)) { 190 | currentActivity = initMcq({ 191 | activity, 192 | state, 193 | postResults, 194 | persistedAnswers 195 | }); 196 | // Store validation function reference 197 | if (currentActivity && typeof currentActivity.validate === 'function') { 198 | validationHandler = currentActivity.validate; 199 | } 200 | } else if (/^matching$/i.test(activity.type)) { 201 | currentActivity = initMatching({ 202 | activity, 203 | state, 204 | postResults, 205 | persistedAnswers 206 | }); 207 | } else { 208 | currentActivity = initSwipe({ 209 | items: state.items, 210 | labels: activity.labels || { left: 'Left', right: 'Right' }, 211 | question: activity.question, 212 | state, 213 | postResults 214 | }); 215 | } 216 | } 217 | 218 | function bindRestart() { 219 | const elRestart = document.getElementById('restart'); 220 | if (!elRestart) return; 221 | // Skip binding for FIB activities - they handle "Clear All" themselves 222 | if (currentActivityData && /^fill in the blanks$/i.test(currentActivityData.type)) { 223 | return; 224 | } 225 | elRestart.addEventListener('click', async (e) => { 226 | e.preventDefault(); 227 | reset(); 228 | const activity2 = await loadActivityJson(); 229 | currentActivityData = activity2; // Update activity data for results 230 | state.items = /^fill in the blanks$/i.test(activity2.type) 231 | ? new Array(activity2.fib.blanks.length).fill(null) 232 | : activity2.items; 233 | // Don't load persisted answers on restart - start fresh 234 | initActivity(activity2, null); 235 | bindRestart(); // re-bind for the newly rendered DOM 236 | }); 237 | } 238 | 239 | // Initialize WebSocket connection 240 | function connectWebSocket() { 241 | try { 242 | const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; 243 | socket = new WebSocket(`${protocol}//${window.location.host}`); 244 | 245 | socket.onopen = () => { 246 | console.log('WebSocket connected'); 247 | }; 248 | 249 | socket.onmessage = (event) => { 250 | try { 251 | const message = JSON.parse(event.data); 252 | if (message.type === 'validate') { 253 | if (validationHandler) { 254 | validationHandler(); 255 | } 256 | } 257 | } catch (e) { 258 | console.error('Error parsing WebSocket message:', e); 259 | } 260 | }; 261 | 262 | socket.onclose = () => { 263 | console.log('WebSocket disconnected'); 264 | // Try to reconnect after a few seconds 265 | setTimeout(connectWebSocket, 5000); 266 | socket = null; 267 | }; 268 | 269 | socket.onerror = (error) => { 270 | console.error('WebSocket error:', error); 271 | }; 272 | } catch (e) { 273 | console.error('Failed to connect WebSocket:', e); 274 | // Try to reconnect after a few seconds 275 | setTimeout(connectWebSocket, 5000); 276 | } 277 | } 278 | 279 | async function start() { 280 | try { 281 | const [activity, persistedData] = await Promise.all([ 282 | loadActivityJson(), 283 | loadAnswers() 284 | ]); 285 | currentActivityData = activity; // Store activity data for results 286 | state.items = /^fill in the blanks$/i.test(activity.type) 287 | ? new Array(activity.fib.blanks.length).fill(null) 288 | : activity.items; 289 | reset(); 290 | 291 | // Validate persisted answers match current activity 292 | const validatedAnswers = validatePersistedAnswers(activity, persistedData); 293 | initActivity(activity, validatedAnswers); 294 | bindRestart(); 295 | 296 | // Connect WebSocket after activity is initialized 297 | connectWebSocket(); 298 | } catch (err) { 299 | // eslint-disable-next-line no-console 300 | console.error(err); 301 | } 302 | } 303 | 304 | start(); 305 | })(); 306 | -------------------------------------------------------------------------------- /PRD_MCQ.md: -------------------------------------------------------------------------------- 1 | # Product Requirements Document: Multiple Choice Question (MCQ) Module 2 | 3 | ## Overview 4 | This document defines the requirements for implementing a Multiple Choice Question (MCQ) activity module that allows users to answer single-select or multi-select multiple choice questions. The module will follow the same architectural patterns as existing activity modules (Fill In The Blanks, Sort Into Boxes, Swipe Left/Right) and support multiple questions per markdown file. 5 | 6 | ## Goals 7 | - Enable users to answer multiple choice questions with single or multiple correct answers 8 | - Support multiple questions per markdown file with scrollable presentation 9 | - Automatically save answers to `answer.md` on every answer change 10 | - Maintain consistency with existing activity module patterns 11 | 12 | ## Markdown File Format 13 | 14 | ### Single Question Format 15 | ``` 16 | __Type__ 17 | 18 | Multiple Choice 19 | 20 | __Practice Question__ 21 | 22 | [Question text here] 23 | 24 | A. [Option A text] 25 | B. [Option B text] 26 | C. [Option C text] 27 | D. [Option D text] 28 | 29 | __Suggested Answers__ 30 | 31 | - A 32 | - B - Correct 33 | - C 34 | - D 35 | ``` 36 | 37 | ### Multiple Questions Format 38 | Multiple questions are supported by repeating the `__Practice Question__` and `__Suggested Answers__` sections: 39 | 40 | ``` 41 | __Type__ 42 | 43 | Multiple Choice 44 | 45 | __Practice Question__ 46 | 47 | [First question text] 48 | 49 | A. [Option A] 50 | B. [Option B] 51 | C. [Option C] 52 | 53 | __Suggested Answers__ 54 | 55 | - A 56 | - B - Correct 57 | - C 58 | 59 | __Practice Question__ 60 | 61 | [Second question text] 62 | 63 | A. [Option A] 64 | B. [Option B] 65 | C. [Option C] 66 | 67 | __Suggested Answers__ 68 | 69 | - A - Correct 70 | - B - Correct 71 | - C 72 | ``` 73 | 74 | ### Answer Detection Rules 75 | - **Single correct answer**: Only one option in `__Suggested Answers__` is marked with `- Correct` 76 | - Use radio button UI (mutually exclusive selection) 77 | - **Multiple correct answers**: Two or more options in `__Suggested Answers__` are marked with `- Correct` 78 | - Use checkbox UI (multiple selections allowed) 79 | 80 | ### Option Format 81 | - Options must be labeled with single letters (A, B, C, D, etc.) followed by a period 82 | - Options can appear in any order in the question text 83 | - The `__Suggested Answers__` section lists all options with their correctness status 84 | - Options without `- Correct` are incorrect answers 85 | 86 | ## Server-Side Requirements 87 | 88 | ### Markdown Parsing (`server.js`) 89 | The `buildActivityFromMarkdown` function must be extended to handle MCQ type: 90 | 91 | 1. **Type Detection** 92 | - Recognize `__Type__` section containing "Multiple Choice" (case-insensitive) 93 | 94 | 2. **Question Parsing** 95 | - Extract all `__Practice Question__` sections (can be multiple) 96 | - For each question: 97 | - Extract the question text (all content between `__Practice Question__` and the next section) 98 | - Parse option labels (A., B., C., etc.) and their text 99 | - Extract corresponding `__Suggested Answers__` section 100 | - Parse which options are marked as "Correct" 101 | - Determine if question is single-select or multi-select based on number of correct answers 102 | 103 | 3. **Activity Object Structure** 104 | ```javascript 105 | { 106 | type: "Multiple Choice", 107 | question: null, // Not used for MCQ (questions are in mcq.questions array) 108 | mcq: { 109 | questions: [ 110 | { 111 | id: 0, // Sequential index 112 | text: "Question text here", 113 | options: [ 114 | { label: "A", text: "Option A text", correct: false }, 115 | { label: "B", text: "Option B text", correct: true }, 116 | { label: "C", text: "Option C text", correct: false }, 117 | { label: "D", text: "Option D text", correct: false } 118 | ], 119 | isMultiSelect: false // true if 2+ correct answers 120 | }, 121 | // ... more questions 122 | ] 123 | } 124 | } 125 | ``` 126 | 127 | 4. **Answer Report Generation** 128 | When posting results to `/api/results`, the server must generate `answer.md` with: 129 | ``` 130 | __Type__ 131 | 132 | Multiple Choice 133 | 134 | __Summary__ 135 | 136 | [X]/[Y] correct 137 | 138 | __Responses__ 139 | 140 | 1. **Question 1** 141 | - Selected Answer: A, B 142 | - Correct Answer: B 143 | - Result: ✗ Incorrect 144 | 145 | 2. **Question 2** 146 | - Selected Answer: A, C 147 | - Correct Answer: A, C 148 | - Result: ✓ Correct 149 | 150 | [For each question, include:] 151 | __Practice Question__ 152 | 153 | [Question text] 154 | 155 | A. [Option A] 156 | B. [Option B] 157 | ... 158 | 159 | __Suggested Answers__ 160 | 161 | - A 162 | - B - Correct 163 | ... 164 | ``` 165 | 166 | ## Client-Side Requirements 167 | 168 | ### Module Structure (`public/modules/mcq.js`) 169 | Follow the same pattern as `fib.js`, `sort.js`, and `swipe.js`: 170 | 171 | 1. **Export Function** 172 | ```javascript 173 | export function initMcq({ activity, state, postResults }) 174 | ``` 175 | 176 | 2. **Initialization** 177 | - Render all questions in a scrollable container 178 | - Each question should be clearly separated visually 179 | - Questions should be numbered (Question 1, Question 2, etc.) 180 | 181 | 3. **UI Components** 182 | 183 | **Single-Select Questions (Radio Buttons)** 184 | - Render as radio button group 185 | - Only one option can be selected at a time 186 | - Selecting a new option deselects the previous one 187 | - Use standard HTML radio inputs with proper labels 188 | 189 | **Multi-Select Questions (Checkboxes)** 190 | - Render as checkbox group 191 | - Multiple options can be selected 192 | - Use standard HTML checkbox inputs with proper labels 193 | 194 | 4. **Answer Tracking** 195 | - Track selected answers per question in local state 196 | - Format: `{ questionId: [selectedOptionLabels] }` 197 | - For single-select: array contains one label (e.g., `["B"]`) 198 | - For multi-select: array contains multiple labels (e.g., `["A", "C"]`) 199 | 200 | 5. **Real-Time Updates** 201 | - Call `postResults()` immediately when any answer changes 202 | - Update `state.results` array before posting: 203 | ```javascript 204 | state.results = activity.mcq.questions.map((q, idx) => { 205 | const selected = selectedAnswers[q.id] || []; 206 | const correct = q.options.filter(opt => opt.correct).map(opt => opt.label); 207 | const isCorrect = arraysEqual(selected.sort(), correct.sort()); 208 | return { 209 | text: `Question ${idx + 1}`, 210 | selected: selected.join(', '), // "B" or "A, C" 211 | correct: correct.join(', ') // "B" or "A, C" 212 | }; 213 | }); 214 | ``` 215 | - Update `state.index` to reflect number of answered questions 216 | 217 | 6. **Visual Feedback** 218 | - Questions should be visually distinct 219 | - Consider adding spacing/padding between questions 220 | - Option labels (A, B, C, D) should be clearly visible 221 | - Selected options should have clear visual indication 222 | 223 | 7. **Accessibility** 224 | - Use proper form semantics (`
`, ``) 225 | - Associate labels with inputs using `for`/`id` attributes 226 | - Support keyboard navigation 227 | - Use ARIA attributes where appropriate 228 | - Ensure screen reader compatibility 229 | 230 | 8. **Cleanup Function** 231 | - Return cleanup function to remove event listeners and DOM elements 232 | - Follow same pattern as other modules 233 | 234 | ### Integration (`public/app.js`) 235 | 1. **Import Module** 236 | ```javascript 237 | import { initMcq } from './modules/mcq.js'; 238 | ``` 239 | 240 | 2. **Activity Initialization** 241 | Add to `initActivity` function: 242 | ```javascript 243 | else if (/^multiple choice$/i.test(activity.type)) { 244 | currentActivity = initMcq({ activity, state, postResults }); 245 | } 246 | ``` 247 | 248 | 3. **State Management** 249 | - MCQ doesn't use `state.items` (leave as empty array or null) 250 | - MCQ uses `state.results` array 251 | - MCQ uses `state.index` to track answered questions 252 | 253 | ## Answer Format Specification 254 | 255 | ### Results Array Structure 256 | Each result object in `state.results`: 257 | ```javascript 258 | { 259 | text: "Question 1", // or "Question 2", etc. 260 | selected: "B", // or "A, C" for multi-select 261 | correct: "B", // or "A, C" for multi-select 262 | } 263 | ``` 264 | 265 | ### Correctness Determination 266 | - **Single-select**: Selected answer matches the single correct answer 267 | - **Multi-select**: 268 | - Selected answers array must match correct answers array exactly 269 | - Order doesn't matter (compare sorted arrays) 270 | - Must have same number of selections as correct answers 271 | 272 | ## Edge Cases & Error Handling 273 | 274 | 1. **No Questions** 275 | - If markdown file has no `__Practice Question__` sections, show error message 276 | 277 | 2. **Missing Suggested Answers** 278 | - If a question has no corresponding `__Suggested Answers__` section, skip that question or show error 279 | 280 | 3. **No Correct Answers** 281 | - If `__Suggested Answers__` has no `- Correct` markers, treat as single-select with no correct answer (all incorrect) 282 | 283 | 4. **Invalid Option Labels** 284 | - Options not matching pattern (Letter + period) should be ignored or handled gracefully 285 | 286 | 5. **Mismatched Options** 287 | - If `__Suggested Answers__` references options not in question text, include them but mark as invalid 288 | 289 | 6. **Empty Selections** 290 | - Allow users to submit with unanswered questions 291 | - Unanswered questions should show as "No answer selected" in results 292 | 293 | ## Testing Considerations 294 | 295 | 1. **Single Question, Single Answer** 296 | - Test with `mcq.md` example 297 | 298 | 2. **Multiple Questions** 299 | - Test with `mcq-2-questions.md` example 300 | - Verify scrolling works 301 | - Verify each question tracks answers independently 302 | 303 | 3. **Multi-Select Questions** 304 | - Test with `mcq-multi-answer.md` example 305 | - Verify checkbox behavior 306 | - Verify multiple selections are tracked correctly 307 | 308 | 4. **Answer Persistence** 309 | - Verify answers are posted to server on every change 310 | - Verify `answer.md` is updated correctly 311 | - Verify answer format matches specification 312 | 313 | 5. **Accessibility** 314 | - Test with keyboard navigation 315 | - Test with screen reader 316 | - Verify proper ARIA attributes 317 | 318 | ## Implementation Notes 319 | 320 | 1. **Styling** 321 | - MCQ module should use consistent styling with other modules 322 | - Consider adding CSS classes: `.mcq`, `.mcq-question`, `.mcq-option`, `.mcq-radio`, `.mcq-checkbox` 323 | - Questions should have sufficient spacing for readability 324 | 325 | 2. **Performance** 326 | - Posting on every change should be efficient (debouncing not required per user request) 327 | - Rendering multiple questions should be performant 328 | 329 | 3. **Future Enhancements** (Out of Scope) 330 | - Question shuffling 331 | - Option shuffling 332 | - Progress indicator 333 | - Question navigation (prev/next buttons) 334 | 335 | ## Success Criteria 336 | 337 | - [ ] MCQ module renders single-select questions with radio buttons 338 | - [ ] MCQ module renders multi-select questions with checkboxes 339 | - [ ] Multiple questions per file are displayed in scrollable format 340 | - [ ] Answers are posted to server on every change 341 | - [ ] `answer.md` file is generated with correct format 342 | - [ ] Correctness is determined accurately for both single and multi-select 343 | - [ ] Module follows same architectural patterns as existing modules 344 | - [ ] Accessibility requirements are met 345 | - [ ] All edge cases are handled gracefully 346 | 347 | -------------------------------------------------------------------------------- /public/modules/matching.js: -------------------------------------------------------------------------------- 1 | import HorizontalCards from '../design-system/components/horizontal-cards/horizontal-cards.js'; 2 | import toolbar from '../components/toolbar.js'; 3 | 4 | export function initMatching({ activity, state, postResults, persistedAnswers = null }) { 5 | const elContainer = document.getElementById('activity-container'); 6 | const matching = activity.matching; 7 | 8 | if (!matching || !matching.items || matching.items.length === 0) { 9 | elContainer.innerHTML = '
No matching items found
'; 10 | return () => { 11 | elContainer.innerHTML = ''; 12 | }; 13 | } 14 | 15 | // Create the matching container 16 | elContainer.innerHTML = ` 17 |
18 |
19 |
20 |
21 | `; 22 | 23 | const elMatching = document.getElementById('matching'); 24 | const elMatchingCardsContainer = document.getElementById('matching-cards-container'); 25 | const elMatchingChoices = document.getElementById('matching-choices'); 26 | 27 | // Selection state - initialize with persisted answers if available 28 | const selectedByItemIdx = matching.items.map((_, idx) => { 29 | if (persistedAnswers && persistedAnswers[idx] !== undefined) { 30 | return persistedAnswers[idx]; 31 | } 32 | return ''; 33 | }); 34 | 35 | let activeCardIndex = null; // Index of currently active card 36 | let horizontalCardsInstance = null; 37 | 38 | // Track usage counts for answer choices 39 | const totalCounts = new Map(); 40 | matching.choices.forEach(c => totalCounts.set(c, (totalCounts.get(c) || 0) + 1)); 41 | 42 | // Get used counts (excluding a specific item index) 43 | function getUsedCounts(exceptIdx) { 44 | const used = new Map(); 45 | selectedByItemIdx.forEach((val, i) => { 46 | if (val && i !== exceptIdx) { 47 | used.set(val, (used.get(val) || 0) + 1); 48 | } 49 | }); 50 | return used; 51 | } 52 | 53 | // Check if a choice is available 54 | function isChoiceAvailable(choice, exceptIdx) { 55 | const used = getUsedCounts(exceptIdx); 56 | const total = totalCounts.get(choice) || 0; 57 | const usedCount = used.get(choice) || 0; 58 | return usedCount < total; 59 | } 60 | 61 | // Create action HTML for a card 62 | function createActionHtml(itemIndex) { 63 | const selected = selectedByItemIdx[itemIndex]; 64 | if (selected) { 65 | return `
${selected}
`; 66 | } else { 67 | return `
Best response
`; 68 | } 69 | } 70 | 71 | // Build cards data for HorizontalCards component 72 | function buildCardsData() { 73 | return matching.items.map((item, idx) => { 74 | // Extract title from item text (e.g., "**Subtle Cue 1**: ..." -> "Subtle Cue 1") 75 | let title = ''; 76 | const titleMatch = item.text.match(/\*\*([^*]+)\*\*/); 77 | if (titleMatch) { 78 | title = titleMatch[1]; 79 | } else { 80 | // Fallback: use "Item 1", "Item 2", etc. 81 | title = `Item ${idx + 1}`; 82 | } 83 | 84 | // Remove title from description 85 | // Since item.textHtml is HTML, we need to remove the HTML version of the title 86 | let description = item.textHtml || item.text; 87 | if (titleMatch && title) { 88 | // Escape the title text for regex (handle special regex characters) 89 | const escapedTitle = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 90 | 91 | // Remove the HTML version from HTML text 92 | // Match patterns like:

Subtle Cue 1: or Subtle Cue 1: 93 | const htmlPatterns = [ 94 | new RegExp(`

\\s*${escapedTitle}\\s*:\\s*`, 'gi'), 95 | new RegExp(`${escapedTitle}\\s*:\\s*`, 'gi') 96 | ]; 97 | 98 | htmlPatterns.forEach(pattern => { 99 | description = description.replace(pattern, ''); 100 | }); 101 | 102 | // Also remove the markdown version if we're using raw text 103 | if (!item.textHtml) { 104 | description = description.replace(new RegExp(`\\*\\*${escapedTitle}\\*\\*:\\s*`), ''); 105 | } 106 | 107 | // Clean up empty paragraph tags and whitespace 108 | description = description.replace(/^

\s*<\/p>\s*/i, ''); 109 | description = description.trim(); 110 | } 111 | 112 | return { 113 | title: title, 114 | description: description, 115 | actionHtml: createActionHtml(idx) 116 | }; 117 | }); 118 | } 119 | 120 | // Initialize HorizontalCards component 121 | function initializeCards() { 122 | const cardsData = buildCardsData(); 123 | 124 | horizontalCardsInstance = new HorizontalCards('#matching-cards-container', { 125 | cards: cardsData, 126 | onCardChange: (index, card) => { 127 | // Update active card index 128 | activeCardIndex = index; 129 | updateChoicesDisplay(); 130 | updateSelectionAreaListeners(); 131 | } 132 | }); 133 | 134 | // Add click handlers to selection areas after cards are created 135 | setTimeout(() => { 136 | updateSelectionAreaListeners(); 137 | }, 100); 138 | } 139 | 140 | // Update selection area listeners 141 | function updateSelectionAreaListeners() { 142 | const selectionAreas = elMatchingCardsContainer.querySelectorAll('.matching-selection-area'); 143 | selectionAreas.forEach(area => { 144 | // Remove existing listeners by cloning 145 | const newArea = area.cloneNode(true); 146 | area.parentNode.replaceChild(newArea, area); 147 | 148 | const itemIndex = parseInt(newArea.getAttribute('data-item-index'), 10); 149 | 150 | newArea.addEventListener('click', () => { 151 | if (selectedByItemIdx[itemIndex]) { 152 | // Clear selection 153 | setSelection(itemIndex, ''); 154 | } else { 155 | // Activate this card (scroll to it if needed, and set as active) 156 | if (horizontalCardsInstance) { 157 | const currentIndex = horizontalCardsInstance.getCurrentIndex(); 158 | if (currentIndex !== itemIndex) { 159 | horizontalCardsInstance.scrollToIndex(itemIndex); 160 | } 161 | } 162 | activeCardIndex = itemIndex; 163 | updateChoicesDisplay(); 164 | } 165 | }); 166 | 167 | newArea.addEventListener('keydown', (e) => { 168 | if (e.key === 'Enter' || e.key === ' ') { 169 | e.preventDefault(); 170 | newArea.click(); 171 | } 172 | }); 173 | 174 | // Make focusable 175 | newArea.setAttribute('tabindex', '0'); 176 | newArea.setAttribute('role', 'button'); 177 | newArea.setAttribute('aria-label', `Selection area for ${itemIndex + 1}`); 178 | }); 179 | } 180 | 181 | // Render answer choices at bottom 182 | function renderChoices() { 183 | elMatchingChoices.innerHTML = ''; 184 | matching.choices.forEach((choice, idx) => { 185 | const choiceButton = document.createElement('button'); 186 | choiceButton.className = 'matching-choice-button button button-primary body-large'; 187 | choiceButton.textContent = choice; 188 | choiceButton.setAttribute('role', 'option'); 189 | choiceButton.setAttribute('aria-label', `Select ${choice}`); 190 | 191 | // Check if this choice is available 192 | const available = isChoiceAvailable(choice, activeCardIndex); 193 | if (!available) { 194 | choiceButton.classList.add('used'); 195 | choiceButton.disabled = true; 196 | } 197 | 198 | choiceButton.addEventListener('click', () => { 199 | // If there's an active card, use it; otherwise get the currently highlighted card 200 | let targetIndex = activeCardIndex; 201 | if (targetIndex === null && horizontalCardsInstance) { 202 | targetIndex = horizontalCardsInstance.getCurrentIndex(); 203 | } 204 | 205 | if (targetIndex !== null && targetIndex >= 0 && targetIndex < matching.items.length) { 206 | // Re-check availability for the target index 207 | const isAvailable = isChoiceAvailable(choice, targetIndex); 208 | if (isAvailable) { 209 | setSelection(targetIndex, choice); 210 | } 211 | } 212 | }); 213 | 214 | elMatchingChoices.appendChild(choiceButton); 215 | }); 216 | } 217 | 218 | // Update choices display (enable/disable based on active card) 219 | function updateChoicesDisplay() { 220 | const choiceButtons = elMatchingChoices.querySelectorAll('.matching-choice-button'); 221 | 222 | // Get the current active card index (either explicitly set or from horizontal-cards) 223 | let currentActiveIndex = activeCardIndex; 224 | if (currentActiveIndex === null && horizontalCardsInstance) { 225 | currentActiveIndex = horizontalCardsInstance.getCurrentIndex(); 226 | } 227 | 228 | choiceButtons.forEach((button, idx) => { 229 | const choice = matching.choices[idx]; 230 | const available = isChoiceAvailable(choice, currentActiveIndex); 231 | 232 | if (!available) { 233 | button.classList.add('used'); 234 | button.disabled = true; 235 | } else { 236 | button.classList.remove('used'); 237 | button.disabled = false; 238 | } 239 | }); 240 | } 241 | 242 | // Set selection for an item 243 | function setSelection(idx, choice) { 244 | selectedByItemIdx[idx] = choice; 245 | 246 | // Deactivate card 247 | activeCardIndex = null; 248 | 249 | // Update the card's selection area directly in the DOM (avoid rebuilding to prevent jumpiness) 250 | const selectionArea = elMatchingCardsContainer.querySelector(`.matching-selection-area[data-item-index="${idx}"]`); 251 | if (selectionArea) { 252 | if (choice) { 253 | selectionArea.textContent = choice; 254 | // Remove empty state classes 255 | selectionArea.classList.remove('empty', 'body-xxsmall'); 256 | // Add matched state classes (button styling) 257 | selectionArea.classList.add('matched', 'button', 'button-primary', 'body-large'); 258 | } else { 259 | selectionArea.textContent = 'Best response'; 260 | // Remove matched state classes 261 | selectionArea.classList.remove('matched', 'button', 'button-primary', 'body-large'); 262 | // Add empty state classes 263 | selectionArea.classList.add('empty', 'body-xxsmall'); 264 | } 265 | } 266 | 267 | updateChoicesDisplay(); 268 | updateResultsAndPost(); 269 | 270 | // Auto-scroll to next unanswered card if selection was made 271 | if (choice && horizontalCardsInstance) { 272 | setTimeout(() => { 273 | // Find next unanswered card 274 | let nextIndex = -1; 275 | for (let i = idx + 1; i < matching.items.length; i++) { 276 | if (!selectedByItemIdx[i] || !selectedByItemIdx[i].trim()) { 277 | nextIndex = i; 278 | break; 279 | } 280 | } 281 | 282 | // If no unanswered card after current, find first unanswered 283 | if (nextIndex === -1) { 284 | for (let i = 0; i < idx; i++) { 285 | if (!selectedByItemIdx[i] || !selectedByItemIdx[i].trim()) { 286 | nextIndex = i; 287 | break; 288 | } 289 | } 290 | } 291 | 292 | if (nextIndex !== -1 && horizontalCardsInstance) { 293 | horizontalCardsInstance.scrollToIndex(nextIndex); 294 | } 295 | }, 300); 296 | } 297 | } 298 | 299 | // Update results and post 300 | function updateResultsAndPost() { 301 | state.results = []; 302 | matching.items.forEach((item, idx) => { 303 | const selected = selectedByItemIdx[idx] || ''; 304 | const correct = item.answer || ''; 305 | state.results.push({ 306 | text: `Item ${idx + 1}`, 307 | selected: selected, 308 | correct: correct 309 | }); 310 | }); 311 | state.index = selectedByItemIdx.reduce((acc, v) => acc + (v ? 1 : 0), 0); 312 | postResults(); 313 | } 314 | 315 | // Initial render 316 | initializeCards(); 317 | renderChoices(); 318 | 319 | // Sync state with persisted answers if they exist 320 | if (persistedAnswers) { 321 | setTimeout(() => { 322 | // Rebuild cards to show persisted selections 323 | if (horizontalCardsInstance) { 324 | horizontalCardsInstance.destroy(); 325 | initializeCards(); 326 | } 327 | updateResultsAndPost(); 328 | }, 100); 329 | } 330 | 331 | // Center the first unanswered card on initial load 332 | setTimeout(() => { 333 | let cardToCenter = 0; 334 | for (let i = 0; i < matching.items.length; i++) { 335 | if (!selectedByItemIdx[i] || !selectedByItemIdx[i].trim()) { 336 | cardToCenter = i; 337 | break; 338 | } 339 | } 340 | 341 | if (horizontalCardsInstance) { 342 | horizontalCardsInstance.scrollToIndex(cardToCenter); 343 | } 344 | }, 200); 345 | 346 | // Clear all answers function 347 | function clearAllAnswers() { 348 | // Clear all selections 349 | matching.items.forEach((_, idx) => { 350 | selectedByItemIdx[idx] = ''; 351 | }); 352 | activeCardIndex = null; 353 | 354 | // Rebuild cards 355 | if (horizontalCardsInstance) { 356 | horizontalCardsInstance.destroy(); 357 | initializeCards(); 358 | } 359 | 360 | updateChoicesDisplay(); 361 | updateResultsAndPost(); 362 | 363 | // Scroll back to first card 364 | setTimeout(() => { 365 | if (horizontalCardsInstance) { 366 | horizontalCardsInstance.scrollToIndex(0); 367 | } 368 | }, 100); 369 | } 370 | 371 | // Register "Clear All" tool in global toolbar 372 | toolbar.registerTool('matching-clear-all', { 373 | icon: 'icon-eraser', 374 | title: 'Clear All', 375 | onClick: (e) => { 376 | e.preventDefault(); 377 | clearAllAnswers(); 378 | }, 379 | enabled: true 380 | }); 381 | 382 | return { 383 | cleanup: () => { 384 | toolbar.unregisterTool('matching-clear-all'); 385 | if (horizontalCardsInstance) { 386 | horizontalCardsInstance.destroy(); 387 | } 388 | elContainer.innerHTML = ''; 389 | } 390 | }; 391 | } 392 | -------------------------------------------------------------------------------- /public/modules/mcq.js: -------------------------------------------------------------------------------- 1 | import toolbar from '../components/toolbar.js'; 2 | 3 | export function initMcq({ activity, state, postResults, persistedAnswers = null }) { 4 | const elContainer = document.getElementById('activity-container'); 5 | const mcq = activity.mcq; 6 | 7 | if (!mcq || !mcq.questions || mcq.questions.length === 0) { 8 | elContainer.innerHTML = '

No MCQ questions found
'; 9 | return () => { 10 | elContainer.innerHTML = ''; 11 | }; 12 | } 13 | 14 | // Create the MCQ container 15 | elContainer.innerHTML = ` 16 |
17 |
18 |
19 | `; 20 | 21 | const elMcq = document.getElementById('mcq'); 22 | const elQuestions = document.getElementById('mcq-questions'); 23 | 24 | // Track selected answers per question 25 | const selectedAnswers = {}; 26 | 27 | // Track validation state 28 | let isValidating = false; 29 | 30 | // Initialize selected answers from persisted answers if available 31 | mcq.questions.forEach(q => { 32 | if (persistedAnswers && persistedAnswers[q.id] !== undefined) { 33 | selectedAnswers[q.id] = Array.isArray(persistedAnswers[q.id]) 34 | ? persistedAnswers[q.id] 35 | : [persistedAnswers[q.id]]; 36 | } else { 37 | selectedAnswers[q.id] = []; 38 | } 39 | }); 40 | 41 | // Render all questions 42 | mcq.questions.forEach((question, qIdx) => { 43 | const questionEl = document.createElement('div'); 44 | questionEl.className = 'mcq-question'; 45 | questionEl.setAttribute('data-question-id', question.id); 46 | questionEl.setAttribute('data-question-index', qIdx.toString()); 47 | 48 | // Question legend (Question 1, Question 2, etc.) 49 | const legend = document.createElement('div'); 50 | legend.className = 'mcq-legend heading-xsmall'; 51 | legend.textContent = `Question ${qIdx + 1}`; 52 | questionEl.appendChild(legend); 53 | 54 | // Question text 55 | const questionTextEl = document.createElement('div'); 56 | questionTextEl.className = 'mcq-question-text body-xlarge'; 57 | questionTextEl.textContent = question.text; 58 | questionEl.appendChild(questionTextEl); 59 | 60 | // Options container 61 | const optionsEl = document.createElement('fieldset'); 62 | optionsEl.className = 'mcq-options'; 63 | 64 | // Create options 65 | question.options.forEach((option, optIdx) => { 66 | const optionEl = document.createElement('label'); 67 | // Apply design system classes to the option label 68 | optionEl.className = question.isMultiSelect 69 | ? 'mcq-option input-checkbox' 70 | : 'mcq-option input-radio'; 71 | 72 | const input = document.createElement('input'); 73 | input.type = question.isMultiSelect ? 'checkbox' : 'radio'; 74 | input.name = `question-${question.id}`; 75 | input.value = option.label; 76 | input.id = `q${question.id}-opt${optIdx}`; 77 | input.setAttribute('aria-label', `Option ${option.label}: ${option.text}`); 78 | 79 | const optionText = document.createElement('span'); 80 | optionText.className = 'mcq-option-text body-large'; 81 | optionText.textContent = option.text; 82 | 83 | const optionLabel = document.createElement('span'); 84 | optionLabel.className = 'mcq-option-label'; 85 | optionLabel.textContent = option.label + '.'; 86 | 87 | // Create option card structure matching Figma design 88 | const optionCard = document.createElement('div'); 89 | optionCard.className = 'mcq-option-card'; 90 | 91 | // Use design system checkbox/radio structure 92 | if (question.isMultiSelect) { 93 | // Checkbox structure: input-checkbox-box with checkmark 94 | const checkboxBox = document.createElement('span'); 95 | checkboxBox.className = 'input-checkbox-box'; 96 | 97 | const checkboxCheckmark = document.createElement('span'); 98 | checkboxCheckmark.className = 'input-checkbox-checkmark'; 99 | 100 | checkboxBox.appendChild(checkboxCheckmark); 101 | 102 | // Text wrapper 103 | const textWrapper = document.createElement('div'); 104 | textWrapper.className = 'mcq-option-content'; 105 | textWrapper.appendChild(optionText); 106 | 107 | optionCard.appendChild(checkboxBox); 108 | optionCard.appendChild(textWrapper); 109 | } else { 110 | // Radio structure: input-radio-circle with dot 111 | const radioCircle = document.createElement('span'); 112 | radioCircle.className = 'input-radio-circle'; 113 | 114 | const radioDot = document.createElement('span'); 115 | radioDot.className = 'input-radio-dot'; 116 | 117 | radioCircle.appendChild(radioDot); 118 | 119 | // Text wrapper 120 | const textWrapper = document.createElement('div'); 121 | textWrapper.className = 'mcq-option-content'; 122 | textWrapper.appendChild(optionText); 123 | 124 | optionCard.appendChild(radioCircle); 125 | optionCard.appendChild(textWrapper); 126 | } 127 | 128 | // Apply persisted answers if available 129 | if (selectedAnswers[question.id] && selectedAnswers[question.id].includes(option.label)) { 130 | input.checked = true; 131 | } 132 | 133 | // Append input first (for CSS sibling selector), then card 134 | optionEl.appendChild(input); 135 | optionEl.appendChild(optionCard); 136 | optionsEl.appendChild(optionEl); 137 | 138 | // Add change listener 139 | input.addEventListener('change', () => { 140 | // Clear validation when user changes any value 141 | clearValidation(); 142 | updateSelection(question.id, option.label, input.checked); 143 | 144 | // For radio questions, center the selected question and auto-scroll after a short delay 145 | if (!question.isMultiSelect && input.checked) { 146 | const questionEl = elQuestions.querySelector(`[data-question-id="${question.id}"]`); 147 | const questionIndex = parseInt(questionEl.getAttribute('data-question-index'), 10); 148 | 149 | // Center the currently selected question 150 | centerQuestion(questionIndex); 151 | 152 | // Auto-scroll to next question after a brief delay (only if not last question) 153 | const isLastQuestion = questionIndex === mcq.questions.length - 1; 154 | if (!isLastQuestion) { 155 | setTimeout(() => { 156 | scrollToNextQuestion(questionIndex); 157 | }, 300); 158 | } 159 | } 160 | }); 161 | }); 162 | 163 | questionEl.appendChild(optionsEl); 164 | 165 | // Add "Next" button for multi-select questions (not on last question) 166 | if (question.isMultiSelect && qIdx < mcq.questions.length - 1) { 167 | const nextButtonContainer = document.createElement('div'); 168 | nextButtonContainer.className = 'mcq-next-button-container'; 169 | 170 | const nextButton = document.createElement('button'); 171 | nextButton.className = 'button button-primary mcq-next-button'; 172 | nextButton.textContent = 'Next'; 173 | nextButton.type = 'button'; 174 | // Enable if persisted answers exist for this question 175 | const hasPersistedAnswers = selectedAnswers[question.id] && selectedAnswers[question.id].length > 0; 176 | nextButton.disabled = !hasPersistedAnswers; 177 | nextButton.setAttribute('aria-label', `Go to next question`); 178 | 179 | // Scroll to next question when button is clicked 180 | nextButton.addEventListener('click', () => { 181 | const questionIndex = parseInt(questionEl.getAttribute('data-question-index'), 10); 182 | scrollToNextQuestion(questionIndex); 183 | }); 184 | 185 | nextButtonContainer.appendChild(nextButton); 186 | questionEl.appendChild(nextButtonContainer); 187 | } 188 | 189 | elQuestions.appendChild(questionEl); 190 | }); 191 | 192 | function findCenteredQuestionIndex() { 193 | const viewportCenter = window.innerHeight / 2 + window.scrollY; 194 | let closestQuestionIndex = 0; 195 | let minDistance = Infinity; 196 | 197 | for (let i = 0; i < elQuestions.children.length; i++) { 198 | const questionEl = elQuestions.children[i]; 199 | const rect = questionEl.getBoundingClientRect(); 200 | const questionCenter = rect.top + rect.height / 2 + window.scrollY; 201 | const distance = Math.abs(viewportCenter - questionCenter); 202 | 203 | if (distance < minDistance) { 204 | minDistance = distance; 205 | closestQuestionIndex = i; 206 | } 207 | } 208 | 209 | return closestQuestionIndex; 210 | } 211 | 212 | function updateQuestionOpacity(centeredQuestionIndex) { 213 | for (let i = 0; i < elQuestions.children.length; i++) { 214 | const questionEl = elQuestions.children[i]; 215 | if (i === centeredQuestionIndex) { 216 | questionEl.classList.add('mcq-question-centered'); 217 | } else { 218 | questionEl.classList.remove('mcq-question-centered'); 219 | } 220 | } 221 | } 222 | 223 | function updateDynamicPadding(centeredQuestionIndex) { 224 | const viewportHeight = window.innerHeight; 225 | 226 | // Calculate padding based on position 227 | const totalQuestions = mcq.questions.length; 228 | const isFirstQuestion = centeredQuestionIndex === 0; 229 | const isLastQuestion = centeredQuestionIndex === totalQuestions - 1; 230 | 231 | // Calculate how much padding is needed 232 | let topPadding = 0; 233 | let bottomPadding = 0; 234 | 235 | if (isFirstQuestion) { 236 | // Need padding at top to center first question 237 | const firstQuestionEl = elQuestions.children[0]; 238 | if (firstQuestionEl) { 239 | const rect = firstQuestionEl.getBoundingClientRect(); 240 | const questionHeight = rect.height; 241 | // Reduce padding slightly (multiply by 0.85) for better centering 242 | const neededPadding = Math.max(0, (viewportHeight - questionHeight) / 2 * 0.85); 243 | topPadding = neededPadding; 244 | } 245 | } 246 | 247 | if (isLastQuestion) { 248 | // Need padding at bottom to center last question 249 | const lastQuestionEl = elQuestions.children[totalQuestions - 1]; 250 | if (lastQuestionEl) { 251 | const rect = lastQuestionEl.getBoundingClientRect(); 252 | const questionHeight = rect.height; 253 | const neededPadding = Math.max(0, (viewportHeight - questionHeight) / 2); 254 | bottomPadding = neededPadding; 255 | } 256 | } 257 | 258 | // Apply padding dynamically 259 | elQuestions.style.paddingTop = `${topPadding}px`; 260 | elQuestions.style.paddingBottom = `${bottomPadding}px`; 261 | } 262 | 263 | function centerQuestion(questionIndex) { 264 | const questionEl = elQuestions.children[questionIndex]; 265 | if (questionEl) { 266 | updateDynamicPadding(questionIndex); 267 | updateQuestionOpacity(questionIndex); 268 | questionEl.scrollIntoView({ 269 | behavior: 'smooth', 270 | block: 'center', 271 | inline: 'nearest' 272 | }); 273 | // Update opacity and padding again after scroll animation completes 274 | setTimeout(() => { 275 | const centeredIndex = findCenteredQuestionIndex(); 276 | updateQuestionOpacity(centeredIndex); 277 | updateDynamicPadding(centeredIndex); 278 | }, 600); 279 | } 280 | } 281 | 282 | function scrollToNextQuestion(currentQuestionIndex) { 283 | const nextQuestionIndex = currentQuestionIndex + 1; 284 | if (nextQuestionIndex < mcq.questions.length) { 285 | centerQuestion(nextQuestionIndex); 286 | } 287 | } 288 | 289 | function updateSelection(questionId, optionLabel, isSelected) { 290 | const question = mcq.questions.find(q => q.id === questionId); 291 | if (!question) return; 292 | 293 | if (question.isMultiSelect) { 294 | // Checkbox: toggle in array 295 | if (isSelected) { 296 | if (!selectedAnswers[questionId].includes(optionLabel)) { 297 | selectedAnswers[questionId].push(optionLabel); 298 | } 299 | } else { 300 | selectedAnswers[questionId] = selectedAnswers[questionId].filter(l => l !== optionLabel); 301 | } 302 | 303 | // Enable/disable next button for multi-select questions based on selection 304 | const questionEl = elQuestions.querySelector(`[data-question-id="${questionId}"]`); 305 | if (questionEl) { 306 | const nextButton = questionEl.querySelector('.mcq-next-button'); 307 | if (nextButton) { 308 | const hasSelection = selectedAnswers[questionId].length > 0; 309 | // Disable if no answer selected 310 | nextButton.disabled = !hasSelection; 311 | } 312 | } 313 | } else { 314 | // Radio: replace array with single selection 315 | selectedAnswers[questionId] = isSelected ? [optionLabel] : []; 316 | 317 | // Uncheck other radio buttons in the same group 318 | const questionEl = elQuestions.querySelector(`[data-question-id="${questionId}"]`); 319 | if (questionEl) { 320 | questionEl.querySelectorAll('input[type="radio"]').forEach(radio => { 321 | if (radio.value !== optionLabel) { 322 | radio.checked = false; 323 | } 324 | }); 325 | 326 | } 327 | } 328 | 329 | updateResultsAndPost(); 330 | } 331 | 332 | function arraysEqual(a, b) { 333 | if (a.length !== b.length) return false; 334 | const sortedA = [...a].sort(); 335 | const sortedB = [...b].sort(); 336 | return sortedA.every((val, idx) => val === sortedB[idx]); 337 | } 338 | 339 | function addErrorIcon(questionEl) { 340 | // Check if icon already exists 341 | if (questionEl.querySelector('.mcq-question-error-icon')) { 342 | return; 343 | } 344 | 345 | const errorIcon = document.createElement('div'); 346 | errorIcon.className = 'mcq-question-error-icon'; 347 | errorIcon.innerHTML = ` 348 | 349 | 350 | 351 | 352 | `; 353 | questionEl.appendChild(errorIcon); 354 | } 355 | 356 | function removeErrorIcon(questionEl) { 357 | const errorIcon = questionEl.querySelector('.mcq-question-error-icon'); 358 | if (errorIcon) { 359 | errorIcon.remove(); 360 | } 361 | } 362 | 363 | function clearValidation() { 364 | if (!isValidating) return; 365 | 366 | isValidating = false; 367 | // Remove validation classes from all questions 368 | mcq.questions.forEach(q => { 369 | const questionEl = elQuestions.querySelector(`[data-question-id="${q.id}"]`); 370 | if (questionEl) { 371 | questionEl.classList.remove('mcq-question-incorrect'); 372 | removeErrorIcon(questionEl); 373 | } 374 | }); 375 | } 376 | 377 | function validateAnswers() { 378 | isValidating = true; 379 | 380 | // Check each question and mark incorrect ones 381 | mcq.questions.forEach(q => { 382 | const selected = selectedAnswers[q.id] || []; 383 | const correct = q.options.filter(opt => opt.correct).map(opt => opt.label); 384 | const isCorrect = arraysEqual(selected.sort(), correct.sort()); 385 | 386 | const questionEl = elQuestions.querySelector(`[data-question-id="${q.id}"]`); 387 | if (questionEl) { 388 | if (!isCorrect) { 389 | questionEl.classList.add('mcq-question-incorrect'); 390 | addErrorIcon(questionEl); 391 | } else { 392 | questionEl.classList.remove('mcq-question-incorrect'); 393 | removeErrorIcon(questionEl); 394 | } 395 | } 396 | }); 397 | } 398 | 399 | function updateResultsAndPost() { 400 | state.results = mcq.questions.map((q, idx) => { 401 | const selected = selectedAnswers[q.id] || []; 402 | const correct = q.options.filter(opt => opt.correct).map(opt => opt.label); 403 | const isCorrect = arraysEqual(selected.sort(), correct.sort()); 404 | 405 | return { 406 | text: `Question ${idx + 1}`, 407 | selected: selected.length > 0 ? selected.join(', ') : '', 408 | correct: correct.join(', ') 409 | }; 410 | }); 411 | 412 | // Count answered questions 413 | state.index = Object.values(selectedAnswers).filter(arr => arr.length > 0).length; 414 | 415 | postResults(); 416 | } 417 | 418 | // Initialize results 419 | updateResultsAndPost(); 420 | 421 | // Clear all answers function 422 | function clearAllAnswers() { 423 | // Clear all selected answers 424 | mcq.questions.forEach(q => { 425 | selectedAnswers[q.id] = []; 426 | }); 427 | 428 | // Uncheck all inputs 429 | elQuestions.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach(input => { 430 | input.checked = false; 431 | }); 432 | 433 | // Clear validation state 434 | clearValidation(); 435 | 436 | // Update results and post 437 | updateResultsAndPost(); 438 | } 439 | 440 | // Register "Clear All" tool in global toolbar 441 | toolbar.registerTool('mcq-clear-all', { 442 | icon: 'icon-eraser', 443 | title: 'Clear All', 444 | onClick: (e) => { 445 | e.preventDefault(); 446 | clearAllAnswers(); 447 | }, 448 | enabled: true 449 | }); 450 | 451 | // Add scroll event listener to update opacity dynamically on manual scroll 452 | let scrollTimeout; 453 | function handleScroll() { 454 | clearTimeout(scrollTimeout); 455 | scrollTimeout = setTimeout(() => { 456 | const centeredIndex = findCenteredQuestionIndex(); 457 | updateQuestionOpacity(centeredIndex); 458 | updateDynamicPadding(centeredIndex); 459 | }, 50); // Debounce scroll events 460 | } 461 | window.addEventListener('scroll', handleScroll, { passive: true }); 462 | window.addEventListener('resize', handleScroll, { passive: true }); 463 | 464 | // Center the first question (or first selected question) on initial load 465 | setTimeout(() => { 466 | let questionToCenter = 0; // Default to first question 467 | 468 | // If persisted answers exist, always scroll to the first question 469 | if (persistedAnswers) { 470 | questionToCenter = 0; 471 | } else { 472 | // Otherwise, check if there's a pre-selected question from state 473 | for (let i = 0; i < mcq.questions.length; i++) { 474 | const q = mcq.questions[i]; 475 | if (!q.isMultiSelect && selectedAnswers[q.id] && selectedAnswers[q.id].length > 0) { 476 | questionToCenter = i; 477 | break; 478 | } 479 | } 480 | } 481 | 482 | centerQuestion(questionToCenter); 483 | // Update opacity and padding after scroll animation completes 484 | setTimeout(() => { 485 | const centeredIndex = findCenteredQuestionIndex(); 486 | updateQuestionOpacity(centeredIndex); 487 | updateDynamicPadding(centeredIndex); 488 | }, 600); // Wait for smooth scroll to complete 489 | }, 100); 490 | 491 | return { 492 | cleanup: () => { 493 | toolbar.unregisterTool('mcq-clear-all'); 494 | window.removeEventListener('scroll', handleScroll); 495 | elContainer.innerHTML = ''; 496 | }, 497 | validate: validateAnswers 498 | }; 499 | } 500 | 501 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const { Lexer, marked } = require('marked'); 5 | const WebSocket = require('ws'); 6 | 7 | const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000; 8 | const PUBLIC_DIR = path.join(__dirname, 'public'); 9 | const DATA_DIR = path.join(__dirname, 'data'); 10 | 11 | const MIME_TYPES = { 12 | '.html': 'text/html; charset=utf-8', 13 | '.css': 'text/css; charset=utf-8', 14 | '.js': 'application/javascript; charset=utf-8', 15 | '.json': 'application/json; charset=utf-8', 16 | '.md': 'text/markdown; charset=utf-8', 17 | '.svg': 'image/svg+xml', 18 | '.png': 'image/png', 19 | '.jpg': 'image/jpeg', 20 | '.jpeg': 'image/jpeg', 21 | '.gif': 'image/gif', 22 | '.ico': 'image/x-icon', 23 | '.txt': 'text/plain; charset=utf-8' 24 | }; 25 | 26 | function isPathInside(child, parent) { 27 | const relative = path.relative(parent, child); 28 | return !!relative && !relative.startsWith('..') && !path.isAbsolute(relative); 29 | } 30 | 31 | function sendFile(res, filePath, status = 200) { 32 | const ext = path.extname(filePath).toLowerCase(); 33 | const contentType = MIME_TYPES[ext] || 'application/octet-stream'; 34 | res.writeHead(status, { 35 | 'Content-Type': contentType, 36 | // Disable aggressive caching during development 37 | 'Cache-Control': 'no-store' 38 | }); 39 | const read = fs.createReadStream(filePath); 40 | read.on('error', () => { 41 | res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' }); 42 | res.end('Internal Server Error'); 43 | }); 44 | read.pipe(res); 45 | } 46 | 47 | function respondJson(res, status, payload) { 48 | res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); 49 | res.end(JSON.stringify(payload)); 50 | } 51 | 52 | function parseSectionsFromTokens(tokens) { 53 | const sections = new Map(); 54 | let current = null; 55 | let buffer = []; 56 | function flush() { 57 | if (current !== null) sections.set(current, buffer.slice()); 58 | buffer = []; 59 | } 60 | for (const token of tokens) { 61 | if (token.type === 'paragraph') { 62 | const text = (token.text || '').trim(); 63 | const m = text.match(/^__([^_]+)__\s*$/); 64 | if (m) { 65 | flush(); 66 | current = m[1].trim(); 67 | continue; 68 | } 69 | } 70 | if (current !== null) buffer.push(token); 71 | } 72 | flush(); 73 | return sections; 74 | } 75 | 76 | function readListItems(sectionTokens) { 77 | const items = []; 78 | for (const t of sectionTokens || []) { 79 | if (t.type === 'list' && Array.isArray(t.items)) { 80 | for (const li of t.items) { 81 | const text = (li.text || '').trim(); 82 | if (text) items.push(text); 83 | } 84 | } else if (t.type === 'paragraph') { 85 | // Support loose list formatting lines starting with - or * 86 | const lines = (t.raw || t.text || '').split(/\r?\n/); 87 | for (const line of lines) { 88 | const m = line.match(/^\s*[-*]\s+(.*)$/); 89 | if (m) items.push(m[1].trim()); 90 | } 91 | } 92 | } 93 | return items; 94 | } 95 | 96 | function parseAnswersFromMarkdown(markdownText) { 97 | const tokens = Lexer.lex(markdownText); 98 | const sections = parseSectionsFromTokens(tokens); 99 | const type = ((sections.get('Type') || []).map(t => t.raw || t.text).join('\n') || '').trim(); 100 | const responsesSection = sections.get('Responses') || []; 101 | 102 | // Parse responses to extract selected answers 103 | const responsesText = responsesSection.map(t => t.raw || t.text || '').join('\n'); 104 | const answers = {}; 105 | 106 | if (/^multiple choice$/i.test(type)) { 107 | // Parse MCQ responses: "Selected Answer: D" or "Selected Answer: B, D" 108 | const responseRegex = /(\d+)\.\s*\*\*[^*]+\*\*[\s\S]*?Selected Answer:\s*([^\n]+)/g; 109 | let match; 110 | while ((match = responseRegex.exec(responsesText)) !== null) { 111 | const questionIndex = parseInt(match[1], 10) - 1; // Convert to 0-indexed 112 | const selectedAnswerStr = match[2].trim(); 113 | // Parse comma-separated answers and trim whitespace 114 | const selectedAnswers = selectedAnswerStr 115 | .split(',') 116 | .map(s => s.trim()) 117 | .filter(Boolean); 118 | if (selectedAnswers.length > 0 && selectedAnswers[0] !== 'No answer selected') { 119 | answers[questionIndex] = selectedAnswers; 120 | } 121 | } 122 | } else if (/^fill in the blanks$/i.test(type)) { 123 | // Parse FIB responses: "Selected Answer: [value]" 124 | const responseRegex = /(\d+)\.\s*\*\*Blank (\d+)\*\*[\s\S]*?Selected Answer:\s*([^\n]+)/g; 125 | let match; 126 | while ((match = responseRegex.exec(responsesText)) !== null) { 127 | const blankIndex = parseInt(match[2], 10) - 1; // Convert to 0-indexed 128 | const selectedAnswer = match[3].trim(); 129 | if (selectedAnswer && selectedAnswer !== 'No answer selected') { 130 | answers[blankIndex] = selectedAnswer; 131 | } 132 | } 133 | } else if (/^matching$/i.test(type)) { 134 | // Parse Matching responses: "Selected Answer: [value]" 135 | const responseRegex = /(\d+)\.\s*\*\*[^*]+\*\*[\s\S]*?Selected Answer:\s*([^\n]+)/g; 136 | let match; 137 | while ((match = responseRegex.exec(responsesText)) !== null) { 138 | const itemIndex = parseInt(match[1], 10) - 1; // Convert to 0-indexed 139 | const selectedAnswer = match[2].trim(); 140 | if (selectedAnswer && selectedAnswer !== 'No answer selected') { 141 | answers[itemIndex] = selectedAnswer; 142 | } 143 | } 144 | } 145 | 146 | return { answers, type }; 147 | } 148 | 149 | function buildActivityFromMarkdown(markdownText) { 150 | const tokens = Lexer.lex(markdownText); 151 | const sections = parseSectionsFromTokens(tokens); 152 | const type = ((sections.get('Type') || []).map(t => t.raw || t.text).join('\n') || '').trim(); 153 | const question = ((sections.get('Practice Question') || []).map(t => t.raw || t.text).join('\n') || '').trim(); 154 | 155 | if (/^fill in the blanks$/i.test(type)) { 156 | const fibTokens = sections.get('Markdown With Blanks') || []; 157 | const fibMarkdown = fibTokens.map(t => t.raw || t.text || '').join('\n').trim(); 158 | const suggested = readListItems(sections.get('Suggested Answers')); 159 | 160 | // Split the content into prompt and fill-in-the-blanks content 161 | const lines = fibMarkdown.split(/\r?\n/); 162 | let promptLines = []; 163 | let contentLines = []; 164 | let foundBlockquote = false; 165 | 166 | for (const line of lines) { 167 | if (/^\s*>/.test(line)) { 168 | foundBlockquote = true; 169 | // Remove the '> ' marker and add to content 170 | contentLines.push(line.replace(/^\s*>\s?/, '')); 171 | } else if (foundBlockquote) { 172 | // After finding blockquote, everything goes to content 173 | contentLines.push(line); 174 | } else { 175 | // Before blockquote, everything goes to prompt (unless it's empty) 176 | if (line.trim()) { 177 | promptLines.push(line); 178 | } 179 | } 180 | } 181 | 182 | const prompt = promptLines.join('\n').trim(); 183 | const content = contentLines.join('\n').trim(); 184 | 185 | const blanks = []; 186 | let idx = 0; 187 | // Replace blank tokens with actual HTML spans that will be preserved by the markdown renderer 188 | const contentWithBlankSpans = content.replace(/\[\[blank:([^\]]+)\]\]/gi, (_, token) => { 189 | const answer = String(token).trim(); 190 | const currentIndex = idx++; 191 | blanks.push({ index: currentIndex, answer }); 192 | return ``; 193 | }); 194 | // Build choices preserving duplicates from Suggested Answers, and ensure 195 | // at least as many copies of each correct answer as there are blanks. 196 | const suggestedTrimmed = suggested.map(s => s.trim()).filter(Boolean); 197 | const requiredCounts = new Map(); 198 | blanks.forEach(b => { 199 | const k = b.answer; 200 | requiredCounts.set(k, (requiredCounts.get(k) || 0) + 1); 201 | }); 202 | const suggestedCounts = new Map(); 203 | suggestedTrimmed.forEach(s => { 204 | suggestedCounts.set(s, (suggestedCounts.get(s) || 0) + 1); 205 | }); 206 | const choices = suggestedTrimmed.slice(); 207 | requiredCounts.forEach((req, k) => { 208 | const have = suggestedCounts.get(k) || 0; 209 | for (let i = have; i < req; i++) choices.push(k); 210 | }); 211 | // simple shuffle 212 | let s = (markdownText.length || 1337) % 2147483647 || 1337; 213 | function rand() { s = (s * 48271) % 2147483647; return s / 2147483647; } 214 | for (let i = choices.length - 1; i > 0; i--) { 215 | const j = Math.floor(rand() * (i + 1)); 216 | [choices[i], choices[j]] = [choices[j], choices[i]]; 217 | } 218 | // Render markdown to HTML for prompt and content 219 | const promptHtml = prompt ? marked.parse(prompt) : ''; 220 | const contentHtml = marked.parse(contentWithBlankSpans); 221 | return { type, question, fib: { raw: fibMarkdown, prompt, promptHtml, content, htmlWithPlaceholders: contentHtml, blanks, choices } }; 222 | } 223 | 224 | if (/^multiple choice$/i.test(type)) { 225 | // Parse MCQ with support for multiple questions 226 | const questions = []; 227 | const allTokens = Lexer.lex(markdownText); 228 | 229 | let currentQuestion = null; 230 | let currentSection = null; 231 | let questionBuffer = []; 232 | let answerBuffer = []; 233 | 234 | // Deterministic shuffle using text as seed 235 | function seededShuffle(array, seed) { 236 | // Simple seeded random number generator 237 | let s = seed; 238 | function seededRandom() { 239 | s = (s * 9301 + 49297) % 233280; 240 | return s / 233280; 241 | } 242 | 243 | // Fisher-Yates shuffle with seeded random 244 | const shuffled = [...array]; 245 | for (let i = shuffled.length - 1; i > 0; i--) { 246 | const j = Math.floor(seededRandom() * (i + 1)); 247 | [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; 248 | } 249 | return shuffled; 250 | } 251 | 252 | // Generate seed from question text and all option texts 253 | function generateSeed(questionText, options) { 254 | const allText = questionText + options.map(opt => opt.text + opt.label).join(''); 255 | let hash = 0; 256 | for (let i = 0; i < allText.length; i++) { 257 | const char = allText.charCodeAt(i); 258 | hash = ((hash << 5) - hash) + char; 259 | hash = hash & hash; // Convert to 32-bit integer 260 | } 261 | return Math.abs(hash); 262 | } 263 | 264 | function processQuestion() { 265 | if (!currentQuestion || questionBuffer.length === 0) return; 266 | 267 | // Parse options from question text 268 | const questionText = questionBuffer.map(t => t.raw || t.text || '').join('\n').trim(); 269 | const options = []; 270 | 271 | // Extract options (A., B., C., etc.) 272 | const optionRegex = /^([A-Z])\.\s*(.+)$/gm; 273 | let match; 274 | const optionMap = new Map(); 275 | while ((match = optionRegex.exec(questionText)) !== null) { 276 | const label = match[1]; 277 | const text = match[2].trim(); 278 | optionMap.set(label, text); 279 | options.push({ 280 | label: label, 281 | text: text, 282 | correct: false // Will be set from Suggested Answers 283 | }); 284 | } 285 | 286 | // Extract question text without options 287 | const questionTextOnly = questionText.replace(/^[A-Z]\.\s*.+$/gm, '').trim(); 288 | 289 | currentQuestion.text = questionTextOnly || questionText; 290 | currentQuestion.options = options; 291 | } 292 | 293 | function processAnswers() { 294 | if (!currentQuestion || answerBuffer.length === 0) return; 295 | 296 | const answerItems = readListItems(answerBuffer); 297 | const correctAnswers = new Set(); 298 | 299 | answerItems.forEach(item => { 300 | const trimmed = item.trim(); 301 | // Match patterns like "A", "A - Correct", "B - Correct", etc. 302 | const match = trimmed.match(/^([A-Z])\s*(?:-?\s*(?:Correct)?)?$/i); 303 | if (match) { 304 | const label = match[1].toUpperCase(); 305 | if (trimmed.toLowerCase().includes('correct')) { 306 | correctAnswers.add(label); 307 | } 308 | } 309 | }); 310 | 311 | // Mark correct options 312 | currentQuestion.options.forEach(opt => { 313 | opt.correct = correctAnswers.has(opt.label); 314 | }); 315 | 316 | // Determine if multi-select 317 | currentQuestion.isMultiSelect = correctAnswers.size > 1; 318 | 319 | // Shuffle options using deterministic seed based on question and option text 320 | const seed = generateSeed(currentQuestion.text, currentQuestion.options); 321 | currentQuestion.options = seededShuffle(currentQuestion.options, seed); 322 | 323 | // Add to questions array 324 | questions.push(currentQuestion); 325 | } 326 | 327 | // Parse tokens sequentially 328 | for (const token of allTokens) { 329 | if (token.type === 'paragraph') { 330 | const text = (token.text || '').trim(); 331 | const m = text.match(/^__([^_]+)__\s*$/); 332 | if (m) { 333 | const sectionName = m[1].trim(); 334 | 335 | if (sectionName === 'Practice Question') { 336 | // Process previous question/answers if any 337 | if (currentQuestion) { 338 | processAnswers(); 339 | } 340 | 341 | // Start new question 342 | currentQuestion = { id: questions.length, text: '', options: [], isMultiSelect: false }; 343 | currentSection = 'question'; 344 | questionBuffer = []; 345 | answerBuffer = []; 346 | continue; 347 | } else if (sectionName === 'Suggested Answers') { 348 | // Process current question text 349 | if (currentQuestion) { 350 | processQuestion(); 351 | currentSection = 'answers'; 352 | answerBuffer = []; 353 | } 354 | continue; 355 | } else if (sectionName === 'Type') { 356 | // Skip type section 357 | currentSection = null; 358 | continue; 359 | } 360 | } 361 | } 362 | 363 | if (currentSection === 'question' && currentQuestion) { 364 | questionBuffer.push(token); 365 | } else if (currentSection === 'answers' && currentQuestion) { 366 | answerBuffer.push(token); 367 | } 368 | } 369 | 370 | // Process last question and answers 371 | if (currentQuestion) { 372 | processAnswers(); 373 | } 374 | 375 | if (questions.length === 0) { 376 | throw new Error('No MCQ questions found'); 377 | } 378 | 379 | return { type, question: null, mcq: { questions } }; 380 | } 381 | 382 | if (/^matching$/i.test(type)) { 383 | const matchingTokens = sections.get('Markdown With Blanks') || []; 384 | const matchingMarkdown = matchingTokens.map(t => t.raw || t.text || '').join('\n').trim(); 385 | const suggested = readListItems(sections.get('Suggested Answers')); 386 | 387 | // Split the content into prompt and matching items 388 | const lines = matchingMarkdown.split(/\r?\n/); 389 | let promptLines = []; 390 | let itemLines = []; 391 | let foundBlockquote = false; 392 | 393 | for (const line of lines) { 394 | if (/^\s*>/.test(line)) { 395 | foundBlockquote = true; 396 | // Remove the '> ' marker and add to items 397 | itemLines.push(line.replace(/^\s*>\s?/, '')); 398 | } else if (foundBlockquote) { 399 | // After finding blockquote, everything goes to items 400 | itemLines.push(line); 401 | } else { 402 | // Before blockquote, everything goes to prompt (unless it's empty) 403 | if (line.trim()) { 404 | promptLines.push(line); 405 | } 406 | } 407 | } 408 | 409 | const prompt = promptLines.join('\n').trim(); 410 | 411 | const items = []; 412 | let idx = 0; 413 | // Parse each blockquote line as a separate item 414 | // Each line starting with '>' is a separate card 415 | for (const line of lines) { 416 | if (/^\s*>/.test(line)) { 417 | const itemLine = line.replace(/^\s*>\s?/, '').trim(); 418 | const blankMatch = itemLine.match(/\[\[blank:([^\]]+)\]\]/i); 419 | if (!blankMatch) continue; 420 | 421 | const answer = String(blankMatch[1]).trim(); 422 | const textBeforeBlank = itemLine.replace(/\[\[blank:[^\]]+\]\]/gi, '').trim(); 423 | 424 | // Render text without blank to HTML 425 | const textHtml = marked.parse(textBeforeBlank); 426 | 427 | items.push({ 428 | index: idx++, 429 | text: textBeforeBlank, 430 | textHtml: textHtml, 431 | answer: answer 432 | }); 433 | } 434 | } 435 | 436 | // Build choices preserving duplicates from Suggested Answers, and ensure 437 | // at least as many copies of each correct answer as there are blanks. 438 | const suggestedTrimmed = suggested.map(s => s.trim()).filter(Boolean); 439 | const requiredCounts = new Map(); 440 | items.forEach(item => { 441 | const k = item.answer; 442 | requiredCounts.set(k, (requiredCounts.get(k) || 0) + 1); 443 | }); 444 | const suggestedCounts = new Map(); 445 | suggestedTrimmed.forEach(s => { 446 | suggestedCounts.set(s, (suggestedCounts.get(s) || 0) + 1); 447 | }); 448 | const choices = suggestedTrimmed.slice(); 449 | requiredCounts.forEach((req, k) => { 450 | const have = suggestedCounts.get(k) || 0; 451 | for (let i = have; i < req; i++) choices.push(k); 452 | }); 453 | 454 | // Shuffle choices using deterministic shuffle 455 | let s = (markdownText.length || 1337) % 2147483647 || 1337; 456 | function rand() { s = (s * 48271) % 2147483647; return s / 2147483647; } 457 | for (let i = choices.length - 1; i > 0; i--) { 458 | const j = Math.floor(rand() * (i + 1)); 459 | [choices[i], choices[j]] = [choices[j], choices[i]]; 460 | } 461 | 462 | // Render markdown to HTML for prompt 463 | const promptHtml = prompt ? marked.parse(prompt) : ''; 464 | 465 | return { 466 | type, 467 | question: null, 468 | matching: { 469 | raw: matchingMarkdown, 470 | prompt, 471 | promptHtml, 472 | items, 473 | choices 474 | } 475 | }; 476 | } 477 | 478 | const labels = readListItems(sections.get('Labels')); 479 | if (/^sort into boxes$/i.test(type)) { 480 | let first = '', second = ''; 481 | for (const entry of labels) { 482 | const [k, ...rest] = entry.split(':'); 483 | const v = rest.join(':').trim(); 484 | const nk = (k || '').toLowerCase(); 485 | if (nk.includes('first')) first = v; 486 | if (nk.includes('second')) second = v; 487 | } 488 | const firstItems = readListItems(sections.get('First Box Items')); 489 | const secondItems = readListItems(sections.get('Second Box Items')); 490 | const items = [ 491 | ...firstItems.map(text => ({ text, correct: 'first' })), 492 | ...secondItems.map(text => ({ text, correct: 'second' })), 493 | ]; 494 | let s = (markdownText.length || 1337) % 2147483647 || 1337; 495 | function rand() { s = (s * 48271) % 2147483647; return s / 2147483647; } 496 | for (let i = items.length - 1; i > 0; i--) { const j = Math.floor(rand() * (i + 1)); [items[i], items[j]] = [items[j], items[i]]; } 497 | return { type, question, labels: { first, second }, items }; 498 | } 499 | 500 | // default swipe left/right 501 | let left = '', right = ''; 502 | for (const entry of labels) { 503 | const [k, ...rest] = entry.split(':'); 504 | const v = rest.join(':').trim(); 505 | const nk = (k || '').toLowerCase(); 506 | if (nk.includes('left')) left = v; 507 | if (nk.includes('right')) right = v; 508 | } 509 | const leftItems = readListItems(sections.get('Left Label Items')); 510 | const rightItems = readListItems(sections.get('Right Label Items')); 511 | const items = [ 512 | ...leftItems.map(text => ({ text, correct: 'left' })), 513 | ...rightItems.map(text => ({ text, correct: 'right' })), 514 | ]; 515 | let s = (markdownText.length || 1337) % 2147483647 || 1337; 516 | function rand() { s = (s * 48271) % 2147483647; return s / 2147483647; } 517 | for (let i = items.length - 1; i > 0; i--) { const j = Math.floor(rand() * (i + 1)); [items[i], items[j]] = [items[j], items[i]]; } 518 | return { type, question, labels: { left, right }, items }; 519 | } 520 | 521 | // Store active WebSocket connections 522 | const clients = new Set(); 523 | 524 | const server = http.createServer((req, res) => { 525 | const urlObj = new URL(req.url, 'http://localhost'); 526 | let pathname = decodeURIComponent(urlObj.pathname || '/'); 527 | 528 | // API: /api/activity 529 | if (pathname === '/api/activity') { 530 | const activityFile = path.join(DATA_DIR, 'question.md'); 531 | fs.readFile(activityFile, 'utf8', (err, data) => { 532 | if (err) { 533 | respondJson(res, 404, { error: 'Activity file not found' }); 534 | return; 535 | } 536 | try { 537 | const activity = buildActivityFromMarkdown(data); 538 | respondJson(res, 200, activity); 539 | } catch (e) { 540 | respondJson(res, 500, { error: 'Failed to parse markdown' }); 541 | } 542 | }); 543 | return; 544 | } 545 | 546 | // API: /api/answers 547 | if (pathname === '/api/answers') { 548 | const answerFile = path.join(DATA_DIR, 'answer.md'); 549 | fs.readFile(answerFile, 'utf8', (err, data) => { 550 | if (err) { 551 | // If file doesn't exist, return empty answers 552 | respondJson(res, 200, { answers: null, type: null }); 553 | return; 554 | } 555 | try { 556 | const { answers, type } = parseAnswersFromMarkdown(data); 557 | respondJson(res, 200, { answers, type }); 558 | } catch (e) { 559 | respondJson(res, 500, { error: 'Failed to parse answers' }); 560 | } 561 | }); 562 | return; 563 | } 564 | 565 | // API: /validate 566 | if (pathname === '/validate' && req.method === 'POST') { 567 | // Send message to all connected WebSocket clients 568 | clients.forEach(client => { 569 | if (client.readyState === WebSocket.OPEN) { 570 | client.send(JSON.stringify({ type: 'validate' })); 571 | } 572 | }); 573 | 574 | respondJson(res, 200, { 575 | status: 'success', 576 | message: 'Validation message sent to all connected clients', 577 | clientCount: clients.size 578 | }); 579 | return; 580 | } 581 | 582 | // API: /api/results 583 | if (pathname === '/api/results' && req.method === 'POST') { 584 | let body = ''; 585 | req.on('data', chunk => { 586 | body += chunk.toString(); 587 | }); 588 | req.on('end', () => { 589 | try { 590 | const data = JSON.parse(body); 591 | const answerFile = path.join(DATA_DIR, 'answer.md'); 592 | 593 | // Format results as markdown 594 | const activity = data.activity; 595 | // For MCQ, compare sorted arrays; for others, exact string match 596 | const correctCount = data.results.filter(result => { 597 | if (activity && /^multiple choice$/i.test(activity.type)) { 598 | // For MCQ, compare sorted comma-separated values 599 | const selected = (result.selected || '').split(',').map(s => s.trim()).filter(Boolean).sort().join(', '); 600 | const correct = (result.correct || '').split(',').map(s => s.trim()).filter(Boolean).sort().join(', '); 601 | return selected === correct; 602 | } 603 | return result.selected === result.correct; 604 | }).length; 605 | const totalCount = data.results.length; 606 | 607 | let markdown = ''; 608 | 609 | // Include original activity details 610 | if (activity) { 611 | markdown += `__Type__\n\n${activity.type}\n\n`; 612 | 613 | // Add results section 614 | markdown += `__Summary__\n\n${correctCount}/${totalCount} correct\n\n`; 615 | markdown += '__Responses__\n\n'; 616 | 617 | data.results.forEach((result, index) => { 618 | markdown += `${index + 1}. **${result.text}**\n`; 619 | markdown += ` - Selected Answer: ${result.selected || 'No answer selected'}\n`; 620 | markdown += ` - Correct Answer: ${result.correct}\n`; 621 | // For MCQ, compare sorted arrays; for others, exact match 622 | let isCorrect = false; 623 | if (activity && /^multiple choice$/i.test(activity.type)) { 624 | const selected = (result.selected || '').split(',').map(s => s.trim()).filter(Boolean).sort().join(', '); 625 | const correct = (result.correct || '').split(',').map(s => s.trim()).filter(Boolean).sort().join(', '); 626 | isCorrect = selected === correct; 627 | } else { 628 | isCorrect = result.selected === result.correct; 629 | } 630 | markdown += ` - Result: ${isCorrect ? '✓ Correct' : '✗ Incorrect'}\n\n`; 631 | }); 632 | 633 | if (/^fill in the blanks$/i.test(activity.type)) { 634 | // For fill-in-the-blanks, include the original markdown with blanks 635 | markdown += `__Markdown With Blanks__\n\n${activity.fib.raw}\n\n`; 636 | markdown += `__Suggested Answers__\n\n`; 637 | activity.fib.choices.forEach(choice => { 638 | markdown += `- ${choice}\n`; 639 | }); 640 | markdown += '\n'; 641 | } else if (/^multiple choice$/i.test(activity.type)) { 642 | // For MCQ, include each question with options and suggested answers 643 | if (activity.mcq && activity.mcq.questions) { 644 | activity.mcq.questions.forEach((q, qIdx) => { 645 | markdown += `__Practice Question__\n\n${q.text}\n\n`; 646 | q.options.forEach(opt => { 647 | markdown += `${opt.label}. ${opt.text}\n`; 648 | }); 649 | markdown += '\n'; 650 | markdown += `__Suggested Answers__\n\n`; 651 | q.options.forEach(opt => { 652 | const correctMarker = opt.correct ? ' - Correct' : ''; 653 | markdown += `- ${opt.label}${correctMarker}\n`; 654 | }); 655 | markdown += '\n'; 656 | }); 657 | } 658 | } else if (/^matching$/i.test(activity.type)) { 659 | // For Matching, include the original markdown with blanks 660 | markdown += `__Markdown With Blanks__\n\n${activity.matching.raw}\n\n`; 661 | markdown += `__Suggested Answers__\n\n`; 662 | activity.matching.choices.forEach(choice => { 663 | markdown += `- ${choice}\n`; 664 | }); 665 | markdown += '\n'; 666 | } else { 667 | // For swipe/sort activities, include question and labels 668 | if (activity.question) { 669 | markdown += `__Practice Question__\n\n${activity.question}\n\n`; 670 | } 671 | 672 | if (activity.labels) { 673 | markdown += `__Labels__\n\n`; 674 | if (/^sort into boxes$/i.test(activity.type)) { 675 | markdown += `- First Box Label: ${activity.labels.first || activity.labels.left || 'First Box'}\n`; 676 | markdown += `- Second Box Label: ${activity.labels.second || activity.labels.right || 'Second Box'}\n\n`; 677 | } else { 678 | markdown += `- Left: ${activity.labels.left || 'Left'}\n`; 679 | markdown += `- Right: ${activity.labels.right || 'Right'}\n\n`; 680 | } 681 | } 682 | } 683 | } 684 | 685 | fs.writeFile(answerFile, markdown, 'utf8', (err) => { 686 | if (err) { 687 | respondJson(res, 500, { error: 'Failed to save results' }); 688 | return; 689 | } 690 | respondJson(res, 200, { success: true }); 691 | }); 692 | } catch (e) { 693 | respondJson(res, 400, { error: 'Invalid JSON' }); 694 | } 695 | }); 696 | return; 697 | } 698 | if (pathname === '/') { 699 | pathname = '/index.html'; 700 | } 701 | 702 | const fsPath = path.join(PUBLIC_DIR, pathname); 703 | 704 | // Prevent directory traversal 705 | if (!isPathInside(fsPath, PUBLIC_DIR) && fsPath !== path.join(PUBLIC_DIR, 'index.html')) { 706 | res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' }); 707 | res.end('Forbidden'); 708 | return; 709 | } 710 | 711 | fs.stat(fsPath, (err, stats) => { 712 | if (err) { 713 | // Fallback to index.html for unknown paths (single page app behavior) 714 | const fallback = path.join(PUBLIC_DIR, 'index.html'); 715 | if (fs.existsSync(fallback)) { 716 | sendFile(res, fallback, 200); 717 | } else { 718 | res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); 719 | res.end('Not Found'); 720 | } 721 | return; 722 | } 723 | if (stats.isDirectory()) { 724 | const indexPath = path.join(fsPath, 'index.html'); 725 | if (fs.existsSync(indexPath)) { 726 | sendFile(res, indexPath, 200); 727 | } else { 728 | res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' }); 729 | res.end('Forbidden'); 730 | } 731 | return; 732 | } 733 | sendFile(res, fsPath, 200); 734 | }); 735 | }); 736 | 737 | server.listen(PORT, () => { 738 | // eslint-disable-next-line no-console 739 | console.log(`Server running at http://localhost:${PORT}`); 740 | }); 741 | 742 | // Create WebSocket server using the ws module 743 | const wss = new WebSocket.Server({ server }); 744 | 745 | wss.on('connection', (ws, req) => { 746 | // Add new client to the Set 747 | clients.add(ws); 748 | console.log('WebSocket connection established, total clients:', clients.size); 749 | 750 | // Handle WebSocket connection close 751 | ws.on('close', () => { 752 | clients.delete(ws); 753 | console.log('WebSocket connection closed, remaining clients:', clients.size); 754 | }); 755 | 756 | // Handle errors 757 | ws.on('error', (error) => { 758 | console.error('WebSocket error:', error); 759 | clients.delete(ws); 760 | }); 761 | }); 762 | 763 | 764 | --------------------------------------------------------------------------------