├── .gitignore ├── client ├── text-to-input.txt ├── stats.txt ├── assets │ ├── heart.svg │ └── meteor.svg ├── config.json ├── example-app │ ├── README.md │ ├── example-app.css │ ├── index.html │ ├── help-content.html │ └── example-app.js ├── app.js ├── help-content-template.html ├── help-content.html ├── help-modal.js ├── index.html ├── bespoke-template.css ├── typing-simulator.css └── typing-simulator.js ├── .gitmodules ├── vite.config.js ├── package.json ├── .github └── workflows │ └── build-release.yml ├── extract_solution.py ├── LICENSE ├── AGENTS.md ├── README.md ├── server.js └── BESPOKE-TEMPLATE.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist/ 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /client/text-to-input.txt: -------------------------------------------------------------------------------- 1 | Another text to enter. Enter this text fast to beat other racers! 2 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "client/design-system"] 2 | path = client/design-system 3 | url = https://github.com/CodeSignal/learn_bespoke-design-system.git 4 | -------------------------------------------------------------------------------- /client/stats.txt: -------------------------------------------------------------------------------- 1 | Score: 0 2 | 3 | Typing Statistics 4 | ================== 5 | 6 | Total Errors Made: 0 7 | Errors Left (Unfixed): 0 8 | Total Time: 10.30 seconds 9 | Accuracy: 100.00% 10 | Speed: 0.00 words per minute 11 | 12 | Generated: 04/12/2025, 16:28:18 13 | -------------------------------------------------------------------------------- /client/assets/heart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "gameType": "meteoriteRain", 3 | "keyboard": true, 4 | "availableKeys": [], 5 | "showStats": true, 6 | "realTimeStats": ["time", "chars"], 7 | "racing": { 8 | "opponentSpeeds": [0.3, 0.4, 0.5], 9 | "mistakesAllowed": 0 10 | }, 11 | "meteoriteRain": { 12 | "meteoriteSpeed": 50, 13 | "spawnInterval": 2000, 14 | "pointsPerChar": 100, 15 | "difficulty": { 16 | "baseSpawnInterval": 2000, 17 | "minSpawnInterval": 1500, 18 | "baseSpeed": 50, 19 | "maxSpeed": 100, 20 | "difficultyIncreaseRate": 0.05 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | 3 | export default defineConfig({ 4 | root: './client', 5 | server: { 6 | host: '0.0.0.0', 7 | hmr: true, 8 | allowedHosts: true, 9 | port: 3000, 10 | proxy: { 11 | '/message': { 12 | target: 'http://localhost:3001', 13 | changeOrigin: true 14 | }, 15 | '/save-stats': { 16 | target: 'http://localhost:3001', 17 | changeOrigin: true 18 | }, 19 | '/ws': { 20 | target: 'ws://localhost:3001', 21 | ws: true, 22 | changeOrigin: true 23 | } 24 | } 25 | }, 26 | build: { 27 | outDir: '../dist', 28 | emptyOutDir: true 29 | } 30 | }); 31 | -------------------------------------------------------------------------------- /client/example-app/README.md: -------------------------------------------------------------------------------- 1 | # Example App 2 | 3 | This directory contains an example application that demonstrates how to use the Bespoke Simulation template and its design system components. The example app showcases a simple interactive counter application that uses buttons, inputs, dropdowns, tags, and other design system components to illustrate the template's features and usage patterns. Accessible via the development server at `http://localhost:3000/example-app/index.html`. 4 | 5 | **Important:** This example app is included for reference and testing purposes only. When customizing this template for your own application, you should remove this entire `example-app` directory and replace it with your own application code: 6 | 7 | ```bash 8 | rm -rf client/example-app 9 | ``` 10 | 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bespoke-template", 3 | "version": "0.0.2", 4 | "description": "Bespoke template with local development server and WebSocket messaging", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "npm run start:prod", 8 | "start:prod": "IS_PRODUCTION=true node server.js", 9 | "start:dev": "concurrently \"npm run dev:vite\" \"npm run dev:api\"", 10 | "dev:vite": "vite", 11 | "dev:api": "PORT=3001 node server.js", 12 | "build": "vite build" 13 | }, 14 | "keywords": [ 15 | "bespoke", 16 | "template", 17 | "development", 18 | "server", 19 | "websocket" 20 | ], 21 | "author": "", 22 | "license": "MIT", 23 | "dependencies": { 24 | "ws": "^8.14.2" 25 | }, 26 | "devDependencies": { 27 | "concurrently": "^8.2.2", 28 | "vite": "^7.2.4" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /client/assets/meteor.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.github/workflows/build-release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | push: 5 | branches: [ main ] 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: Setup Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: '22.13.1' 22 | cache: 'npm' 23 | 24 | - name: Install all dependencies 25 | run: npm ci 26 | 27 | - name: Build project 28 | run: npm run build 29 | 30 | - name: Install production dependencies only 31 | run: | 32 | npm ci --production 33 | 34 | - name: Create release tarball 35 | run: | 36 | tar -czf release.tar.gz dist/ package.json server.js node_modules/ 37 | 38 | - name: Upload build artifact (for workflow logs) 39 | uses: actions/upload-artifact@v4 40 | with: 41 | name: dist 42 | path: dist 43 | 44 | - name: Create GitHub Release and upload asset 45 | uses: ncipollo/release-action@v1 46 | with: 47 | token: ${{ secrets.GITHUB_TOKEN }} 48 | tag: v${{ github.run_number }} 49 | name: Release ${{ github.run_number }} 50 | body: | 51 | Latest build from main branch. 52 | artifacts: release.tar.gz 53 | allowUpdates: false 54 | draft: false 55 | prerelease: false 56 | makeLatest: true 57 | -------------------------------------------------------------------------------- /client/app.js: -------------------------------------------------------------------------------- 1 | // app.js 2 | (function() { 3 | let websocket = null; 4 | 5 | function setStatus(msg) { 6 | // Status element removed - function kept for compatibility but does nothing 7 | } 8 | 9 | // Initialize WebSocket connection 10 | function initializeWebSocket() { 11 | const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; 12 | const host = window.location.host; 13 | const wsUrl = `${protocol}//${host}/ws`; 14 | 15 | try { 16 | websocket = new WebSocket(wsUrl); 17 | 18 | websocket.onopen = function(event) { 19 | console.log('WebSocket connected'); 20 | setStatus('Ready (WebSocket connected)'); 21 | }; 22 | 23 | websocket.onmessage = function(event) { 24 | try { 25 | const data = JSON.parse(event.data); 26 | if (data.type === 'message' && data.message) { 27 | alert(data.message); 28 | } 29 | } catch (error) { 30 | console.error('Error parsing WebSocket message:', error); 31 | } 32 | }; 33 | 34 | websocket.onclose = function(event) { 35 | console.log('WebSocket disconnected'); 36 | setStatus('Ready (WebSocket disconnected)'); 37 | 38 | // Attempt to reconnect after 3 seconds 39 | setTimeout(() => { 40 | console.log('Attempting to reconnect WebSocket...'); 41 | initializeWebSocket(); 42 | }, 3000); 43 | }; 44 | 45 | websocket.onerror = function(error) { 46 | console.error('WebSocket error:', error); 47 | setStatus('Ready (WebSocket error)'); 48 | }; 49 | 50 | } catch (error) { 51 | console.error('Failed to create WebSocket connection:', error); 52 | setStatus('Ready (WebSocket unavailable)'); 53 | } 54 | } 55 | 56 | // Load help content and initialize modal 57 | async function initializeHelpModal() { 58 | try { 59 | const response = await fetch('./help-content.html'); 60 | const helpContent = await response.text(); 61 | 62 | // Initialize help modal with actual content 63 | HelpModal.init({ 64 | triggerSelector: '#btn-help', 65 | content: helpContent, 66 | theme: 'auto' 67 | }); 68 | 69 | setStatus('Ready'); 70 | } catch (error) { 71 | console.error('Failed to load help content:', error); 72 | // Fallback to placeholder content 73 | HelpModal.init({ 74 | triggerSelector: '#btn-help', 75 | content: '

Help content could not be loaded. Please check that help-content.html exists.

', 76 | theme: 'auto' 77 | }); 78 | setStatus('Ready (help content unavailable)'); 79 | } 80 | } 81 | 82 | // Initialize both help modal and WebSocket when DOM is ready 83 | function initialize() { 84 | initializeHelpModal(); 85 | initializeWebSocket(); 86 | } 87 | 88 | if (document.readyState === 'loading') { 89 | document.addEventListener('DOMContentLoaded', initialize); 90 | } else { 91 | initialize(); 92 | } 93 | })(); 94 | -------------------------------------------------------------------------------- /client/example-app/example-app.css: -------------------------------------------------------------------------------- 1 | /* Example App Styles - Interactive Component Showcase */ 2 | 3 | .bespoke .sidebar { 4 | padding: var(--UI-Spacing-spacing-xl); 5 | overflow-y: auto; 6 | } 7 | 8 | .bespoke .sidebar-section { 9 | display: flex; 10 | flex-direction: column; 11 | gap: var(--UI-Spacing-spacing-xl); 12 | } 13 | 14 | .bespoke .sidebar-section h2 { 15 | font-size: var(--Fonts-Headlines-sm); 16 | font-weight: 600; 17 | color: var(--Colors-Text-Body-Strongest); 18 | margin: 0 0 var(--UI-Spacing-spacing-ml) 0; 19 | } 20 | 21 | .bespoke .control-group { 22 | display: flex; 23 | flex-direction: column; 24 | gap: var(--UI-Spacing-spacing-s); 25 | } 26 | 27 | .bespoke .control-group label { 28 | font-size: var(--Fonts-Body-Default-sm); 29 | font-weight: 500; 30 | color: var(--Colors-Text-Body-Strong); 31 | } 32 | 33 | .bespoke .control-group .button { 34 | width: 100%; 35 | } 36 | 37 | .bespoke .control-group-buttons { 38 | flex-direction: row; 39 | gap: var(--UI-Spacing-spacing-s); 40 | } 41 | 42 | .bespoke .control-group-buttons .button { 43 | flex: 1; 44 | } 45 | 46 | .bespoke .content-area { 47 | overflow-y: auto; 48 | padding: var(--UI-Spacing-spacing-xl); 49 | } 50 | 51 | .bespoke .display-container { 52 | max-width: 800px; 53 | margin: 0 auto; 54 | display: flex; 55 | flex-direction: column; 56 | gap: var(--UI-Spacing-spacing-xl); 57 | } 58 | 59 | .bespoke .counter-display { 60 | flex-direction: column; 61 | align-items: center; 62 | justify-content: center; 63 | padding: var(--UI-Spacing-spacing-2xl); 64 | text-align: center; 65 | } 66 | 67 | .bespoke .counter-display h2 { 68 | font-size: var(--Fonts-Headlines-sm); 69 | font-weight: 600; 70 | color: var(--Colors-Text-Body-Strong); 71 | margin: 0 0 var(--UI-Spacing-spacing-ml) 0; 72 | text-align: center; 73 | } 74 | 75 | .bespoke .counter-value { 76 | font-size: 4rem; 77 | font-weight: 700; 78 | font-family: var(--heading-family); 79 | color: var(--Colors-Text-Body-Strongest); 80 | line-height: 1; 81 | text-align: center; 82 | } 83 | 84 | .bespoke .tags-container { 85 | display: flex; 86 | gap: var(--UI-Spacing-spacing-ml); 87 | justify-content: center; 88 | flex-wrap: wrap; 89 | } 90 | 91 | .bespoke .settings-display { 92 | flex-direction: column; 93 | align-items: flex-start; 94 | justify-content: flex-start; 95 | padding: var(--UI-Spacing-spacing-xl); 96 | } 97 | 98 | .bespoke .settings-display h3 { 99 | font-size: var(--Fonts-Headlines-xs); 100 | font-weight: 600; 101 | color: var(--Colors-Text-Body-Strong); 102 | margin: 0 0 var(--UI-Spacing-spacing-ml) 0; 103 | width: 100%; 104 | } 105 | 106 | .bespoke .setting-item { 107 | font-size: var(--Fonts-Body-Default-sm); 108 | color: var(--Colors-Text-Body-Default); 109 | margin-bottom: var(--UI-Spacing-spacing-s); 110 | width: 100%; 111 | } 112 | 113 | .bespoke .setting-item:last-child { 114 | margin-bottom: 0; 115 | } 116 | 117 | .bespoke .setting-item strong { 118 | color: var(--Colors-Text-Body-Strong); 119 | margin-right: var(--UI-Spacing-spacing-xs); 120 | } 121 | -------------------------------------------------------------------------------- /extract_solution.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Extract and print typing statistics from stats.txt file. 4 | """ 5 | 6 | import os 7 | import re 8 | from pathlib import Path 9 | 10 | 11 | def extract_stats(): 12 | """Read stats.txt and print all statistics to console.""" 13 | # Get the project root directory (where this script is located) 14 | script_dir = Path(__file__).parent 15 | stats_file = script_dir / 'client' / 'stats.txt' 16 | 17 | # Check if stats file exists 18 | if not stats_file.exists(): 19 | print(f"Error: stats.txt not found at {stats_file}") 20 | return 21 | 22 | # Read the stats file 23 | try: 24 | with open(stats_file, 'r', encoding='utf-8') as f: 25 | content = f.read() 26 | except Exception as e: 27 | print(f"Error reading stats.txt: {e}") 28 | return 29 | 30 | # Parse and extract statistics 31 | stats = {} 32 | 33 | # Extract Total Errors Made 34 | match = re.search(r'Total Errors Made:\s*(\d+)', content) 35 | if match: 36 | stats['total_errors'] = int(match.group(1)) 37 | 38 | # Extract Errors Left (Unfixed) 39 | match = re.search(r'Errors Left \(Unfixed\):\s*(\d+)', content) 40 | if match: 41 | stats['errors_left'] = int(match.group(1)) 42 | 43 | # Extract Total Time 44 | match = re.search(r'Total Time:\s*([\d.]+)\s*seconds', content) 45 | if match: 46 | stats['total_time'] = float(match.group(1)) 47 | 48 | # Extract Accuracy 49 | match = re.search(r'Accuracy:\s*([\d.]+)%', content) 50 | if match: 51 | stats['accuracy'] = float(match.group(1)) 52 | 53 | # Extract Speed (WPM) 54 | match = re.search(r'Speed:\s*([\d.]+)\s*words per minute', content) 55 | if match: 56 | stats['speed'] = float(match.group(1)) 57 | 58 | # Extract Generated timestamp 59 | match = re.search(r'Generated:\s*(.+)', content) 60 | if match: 61 | stats['generated'] = match.group(1).strip() 62 | 63 | # Extract Status (win/lose) for racing games 64 | match = re.search(r'Status:\s*(win|lose)', content, re.IGNORECASE) 65 | if match: 66 | stats['status'] = match.group(1).lower() 67 | 68 | # Extract Score for meteorite rain games 69 | match = re.search(r'Score:\s*(\d+)', content, re.IGNORECASE) 70 | if match: 71 | stats['score'] = int(match.group(1)) 72 | 73 | # Print all statistics 74 | # Print status first if available (for racing games) 75 | if 'status' in stats: 76 | status_display = "WIN" if stats['status'] == 'win' else "LOSE" 77 | print(f"Status: {status_display}") 78 | print("=" * 50) 79 | print() 80 | 81 | # Print score if available (for meteorite rain games) 82 | if 'score' in stats: 83 | print(f"Score: {stats['score']}") 84 | print("=" * 50) 85 | print() 86 | 87 | print("Typing Statistics") 88 | print("=" * 50) 89 | print() 90 | 91 | if 'total_errors' in stats: 92 | print(f"Total Errors Made: {stats['total_errors']}") 93 | 94 | if 'errors_left' in stats: 95 | print(f"Errors Left (Unfixed): {stats['errors_left']}") 96 | 97 | if 'total_time' in stats: 98 | time_value = stats['total_time'] 99 | if time_value < 60: 100 | print(f"Total Time: {time_value:.2f} seconds") 101 | else: 102 | minutes = int(time_value // 60) 103 | seconds = time_value % 60 104 | print(f"Total Time: {minutes}m {seconds:.2f}s") 105 | 106 | if 'accuracy' in stats: 107 | print(f"Accuracy: {stats['accuracy']:.2f}%") 108 | 109 | if 'speed' in stats: 110 | print(f"Speed: {stats['speed']:.2f} words per minute") 111 | 112 | if 'generated' in stats: 113 | print(f"Generated: {stats['generated']}") 114 | 115 | print() 116 | 117 | 118 | if __name__ == '__main__': 119 | extract_stats() 120 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # Repository Guidelines 2 | 3 | This repository contains a template for building embedded applications using 4 | the Bespoke Simulation framework. For complete template documentation, see 5 | [BESPOKE-TEMPLATE.md](./BESPOKE-TEMPLATE.md). 6 | 7 | ## Overview 8 | 9 | This template provides: 10 | - CodeSignal Design System integration 11 | - Consistent layout components (header, sidebar, main content area) 12 | - Help modal system 13 | - Local development server with WebSocket support 14 | - Standardized file structure and naming conventions 15 | 16 | ## Quick Start 17 | 18 | 1. **Customize the HTML template** (`client/index.html`): 19 | - Replace `` with your page title 20 | - Replace `` with your app name 21 | - Add your main content at `` 22 | - Add app-specific CSS links at `` 23 | - Add app-specific JavaScript at `` 24 | 25 | 2. **Create your application files**: 26 | - App-specific CSS (e.g., `my-app.css`) 27 | - App-specific JavaScript (e.g., `my-app.js`) 28 | - Help content (based on `help-content-template.html`) 29 | 30 | 3. **Start the development server**: 31 | ```bash 32 | npm start 33 | ``` 34 | Server runs on `http://localhost:3000` 35 | 36 | ## Key Conventions 37 | 38 | ### Status Messages 39 | 40 | Use these exact status messages for consistency: 41 | 42 | - "Ready" - Application loaded successfully 43 | - "Loading..." - Data is being loaded 44 | - "Saving..." - Data is being saved 45 | - "Changes saved" - Auto-save completed successfully 46 | - "Save failed (will retry)" - Server save failed, will retry 47 | - "Failed to load data" - Data loading failed 48 | - "Auto-save initialized" - Auto-save system started 49 | 50 | ### File Naming 51 | 52 | - CSS files: kebab-case (e.g., `my-app.css`) 53 | - JavaScript files: kebab-case (e.g., `my-app.js`) 54 | - Data files: kebab-case (e.g., `solution.json`) 55 | - Image files: kebab-case (e.g., `overview.png`) 56 | 57 | ### Error Handling 58 | 59 | - Wrap all async operations in try-catch blocks 60 | - Provide meaningful error messages to users 61 | - Log errors to console for debugging 62 | - Implement retry logic for network operations 63 | - Handle localStorage quota exceeded errors 64 | - Validate data before saving operations 65 | 66 | ## Development Workflow 67 | 68 | ### Build and Test 69 | 70 | ```bash 71 | # Start development server 72 | npm start 73 | 74 | # Development mode (same as start) 75 | npm run dev 76 | ``` 77 | 78 | ### WebSocket Messaging 79 | 80 | The server provides a `POST /message` endpoint for real-time messaging: 81 | 82 | ```bash 83 | curl -X POST http://localhost:3000/message \ 84 | -H "Content-Type: application/json" \ 85 | -d '{"message": "Your message here"}' 86 | ``` 87 | 88 | This sends alerts to connected clients. Requires `ws` package: 89 | ```bash 90 | npm install 91 | ``` 92 | 93 | ## Template Documentation 94 | 95 | For detailed information about: 96 | - Design System usage and components 97 | - CSS implementation guidelines 98 | - JavaScript API (HelpModal, status management) 99 | - Component reference and examples 100 | - Customization options 101 | 102 | See [BESPOKE-TEMPLATE.md](./BESPOKE-TEMPLATE.md). 103 | 104 | ## Project Structure 105 | 106 | ``` 107 | client/ 108 | ├── index.html # Main HTML template 109 | ├── app.js # Application logic 110 | ├── bespoke-template.css # Template-specific styles 111 | ├── help-modal.js # Help modal system 112 | ├── help-content-template.html # Help content template 113 | └── design-system/ # CodeSignal Design System 114 | ├── colors/ 115 | ├── spacing/ 116 | ├── typography/ 117 | └── components/ 118 | server.js # Development server 119 | ``` 120 | 121 | ## Notes for AI Agents 122 | 123 | When working on applications built with this template: 124 | 125 | 1. **Always reference BESPOKE-TEMPLATE.md** for template-specific 126 | implementation details 127 | 2. **Follow the conventions** listed above for status messages and file naming 128 | 3. **Use Design System components** directly - see BESPOKE-TEMPLATE.md for 129 | component classes and usage 130 | 4. **Maintain consistency** with the template's structure and patterns 131 | 5. **Keep guidelines up to date** by editing this AGENTS.md file as the codebase evolves -------------------------------------------------------------------------------- /client/example-app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Design System Component Showcase 6 | 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 |

Component Showcase

32 |
33 |
Ready
34 | 35 |
36 | 37 | 38 |
39 | 40 | 64 | 65 | 66 |
67 |
68 | 69 |
70 |

Counter

71 |
0
72 |
73 | 74 | 75 |
76 |
Active
77 | 78 | 79 |
80 | 81 | 82 |
83 |

Current Settings

84 |
85 | Label: Counter 86 |
87 |
88 | Increment: 1 89 |
90 |
91 |
92 |
93 |
94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | -------------------------------------------------------------------------------- /client/help-content-template.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 19 | 20 | 21 |
22 |

Overview

23 |

This page explains how to use the : .

24 | 25 | 26 |
27 | 28 | 29 |
30 |

Getting Started

31 |

To begin using the :

32 |
    33 |
  1. First step...
  2. 34 |
  3. Second step...
  4. 35 |
  5. Third step...
  6. 36 |
37 | 38 | 39 |
40 | 41 | 42 |
43 |

Key Features

44 | 45 |

Feature 1

46 |

Description of feature 1 and how to use it.

47 | 48 |

Feature 2

49 |

Description of feature 2 and how to use it.

50 | 51 |

Feature 3

52 |

Description of feature 3 and how to use it.

53 | 54 | 55 | 56 |
57 | 58 | 59 |
60 |

Workflow

61 |

Here's the typical workflow for using this application:

62 |
    63 |
  1. Step 1: Description of first workflow step
  2. 64 |
  3. Step 2: Description of second workflow step
  4. 65 |
  5. Step 3: Description of third workflow step
  6. 66 |
  7. Step 4: Description of fourth workflow step
  8. 67 |
68 | 69 |

Tips & Best Practices

70 | 75 |
76 | 77 | 78 |
79 |

Shortcuts

80 | 87 |
88 | 89 | 90 |
91 |

Troubleshooting / FAQ

92 | 93 |
94 | Common question 1? 95 |

Answer to common question 1 with helpful details.

96 |
97 | 98 |
99 | Common question 2? 100 |

Answer to common question 2 with helpful details.

101 |
102 | 103 |
104 | Common question 3? 105 |

Answer to common question 3 with helpful details.

106 |
107 | 108 |
109 | How do I add images to the help? 110 |

Place image files in the help/img/ directory and reference them with relative paths like <img src="./img/example.png" alt="Description">

111 |
112 | 113 |
114 | Is my work automatically saved? 115 |

Yes, the app automatically saves your work. You'll see status messages indicating when saves occur.

116 |
117 |
118 | 119 | 137 | -------------------------------------------------------------------------------- /client/example-app/help-content.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 13 | 14 | 15 |
16 |

Overview

17 |

This is an interactive showcase application that demonstrates Design System components through a clicker counter app. The app features input controls in the sidebar and visual components in the main content area that update in real-time based on your interactions.

18 |

This showcase demonstrates:

19 | 25 |
26 | 27 | 28 |
29 |

Sidebar Controls

30 |

The sidebar contains input controls that affect the counter:

31 | 32 |

Counter Label

33 |

Use the text input field to change the label displayed above the counter value. Type any text you want, and it will update immediately in the main display area.

34 | 35 |

Increment Amount

36 |

Select the increment amount from the dropdown menu. You can choose:

37 | 43 |

The selected increment amount is displayed in the settings panel below the counter.

44 | 45 |

Action Buttons

46 | 51 |
52 | 53 | 54 |
55 |

Main Display Area

56 |

The main content area shows visual components that update based on your interactions:

57 | 58 |

Counter Display

59 |

The counter value is displayed prominently in a card box component. The label above the counter updates when you change it in the sidebar.

60 | 61 |

Status Tags

62 |

Status tags automatically update based on the counter value:

63 | 68 | 69 |

Action Buttons

70 |

Action buttons in the display area mirror the sidebar controls. You can use either set of buttons to control the counter.

71 | 72 |

Settings Display

73 |

The settings panel shows the current label and increment amount in a read-only card component, providing a clear view of the current configuration.

74 |
75 | 76 | 77 |
78 |

Design System Components Used

79 |

This app demonstrates the following Design System components:

80 | 81 |

Buttons

82 | 87 | 88 |

Boxes/Cards

89 | 92 | 93 |

Tags

94 | 99 | 100 |

Dropdown

101 | 104 | 105 |

Input

106 | 109 |
110 | -------------------------------------------------------------------------------- /client/help-content.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 15 | 16 | 17 |
18 |

Overview

19 |

The Typing Simulator is a practice tool that helps you improve your typing speed and accuracy. Type the displayed text character by character, and see real-time feedback on your performance.

20 |
21 | 22 | 23 |
24 |

Getting Started

25 |

To begin using the Typing Simulator:

26 |
    27 |
  1. The text to type will be displayed in grey
  2. 28 |
  3. Click on the text area or start typing to begin
  4. 29 |
  5. Type each character as it appears in the text
  6. 30 |
  7. Watch as correctly typed characters turn black and mistakes turn red
  8. 31 |
  9. Use backspace to fix errors if needed
  10. 32 |
  11. When you finish, you'll see a completion screen
  12. 33 |
34 |
35 | 36 | 37 |
38 |

Key Features

39 | 40 |

Real-time Feedback

41 |

See immediate visual feedback as you type. Correct characters turn black, while mistakes are highlighted in red.

42 | 43 |

Error Correction

44 |

You can use backspace or delete to go back and fix mistakes. The simulator tracks your progress and allows you to correct errors at any time.

45 | 46 |

Completion Screen

47 |

When you successfully type the entire text, a completion screen appears with an option to start over and practice again.

48 | 49 |

Restart Anytime

50 |

Use the restart button in the bottom left corner to start over at any time during your practice session.

51 |
52 | 53 | 54 |
55 |

How to Use

56 |

Here's the typical workflow for using the Typing Simulator:

57 |
    58 |
  1. Start Typing: Click on the text area or simply start typing. The text will be loaded automatically.
  2. 59 |
  3. Type Carefully: Type each character as it appears. Focus on accuracy first, then speed.
  4. 60 |
  5. Handle Mistakes: If you make a mistake, it will be highlighted in red. You can continue typing or use backspace to fix it.
  6. 61 |
  7. Complete the Text: Keep typing until you've completed the entire text.
  8. 62 |
  9. Review and Restart: After completion, you can start over to practice again.
  10. 63 |
64 | 65 |

Tips & Best Practices

66 | 73 |
74 | 75 | 76 |
77 |

Shortcuts

78 | 84 |
85 | 86 | 87 |
88 |

Troubleshooting / FAQ

89 | 90 |
91 | What happens if I make a mistake? 92 |

Mistakes are highlighted in red, but you can continue typing. You can also use backspace to go back and fix the error. The simulator tracks your progress and allows you to correct mistakes at any time.

93 |
94 | 95 |
96 | Can I restart in the middle of typing? 97 |

Yes! Click the restart button in the bottom left corner at any time to start over from the beginning.

98 |
99 | 100 |
101 | What text will I be typing? 102 |

The text is loaded from the text-to-input.txt file. This file contains practice text designed to help you improve your typing skills.

103 |
104 | 105 |
106 | How do I know when I'm done? 107 |

When you successfully type the entire text correctly, a completion screen will appear with a "Start Over" button.

108 |
109 | 110 |
111 | Can I change the text I'm typing? 112 |

The text is loaded from text-to-input.txt. You can modify this file to practice with different text.

113 |
114 |
115 | -------------------------------------------------------------------------------- /client/help-modal.js: -------------------------------------------------------------------------------- 1 | /** 2 | * HelpModal - A reusable, dependency-free help modal system for Bespoke applications 3 | * 4 | * This modal system is designed to work with the CodeSignal Design System and provides 5 | * a consistent help experience across all embedded applications. 6 | * 7 | * Note: Currently uses temporary modal components from bespoke-template.css. 8 | * When modal components are added to the design system, this should be updated to use them. 9 | * 10 | * Usage: 11 | * HelpModal.init({ 12 | * triggerSelector: '#btn-help', 13 | * content: helpContent, 14 | * theme: 'auto' 15 | * }); 16 | */ 17 | 18 | class HelpModal { 19 | constructor(options = {}) { 20 | this.options = { 21 | triggerSelector: '#btn-help', 22 | content: '', 23 | theme: 'auto', // 'light', 'dark', or 'auto' 24 | customStyles: {}, 25 | ...options 26 | }; 27 | 28 | this.isOpen = false; 29 | this.modal = null; 30 | this.trigger = null; 31 | 32 | this.init(); 33 | } 34 | 35 | init() { 36 | this.createModal(); 37 | this.bindEvents(); 38 | } 39 | 40 | createModal() { 41 | // Create modal container using temporary modal classes from bespoke-template.css 42 | // TODO: Update to use design system modal classes when available 43 | this.modal = document.createElement('div'); 44 | this.modal.className = 'modal'; 45 | this.modal.innerHTML = ` 46 | 47 | 56 | `; 57 | 58 | // Initially hidden 59 | this.modal.style.display = 'none'; 60 | document.body.appendChild(this.modal); 61 | } 62 | 63 | bindEvents() { 64 | // Find trigger element 65 | this.trigger = document.querySelector(this.options.triggerSelector); 66 | if (!this.trigger) { 67 | console.warn(`HelpModal: Trigger element '${this.options.triggerSelector}' not found`); 68 | return; 69 | } 70 | 71 | // Convert link to button if needed 72 | if (this.trigger.tagName === 'A') { 73 | this.trigger.addEventListener('click', (e) => { 74 | e.preventDefault(); 75 | this.open(); 76 | }); 77 | } else { 78 | this.trigger.addEventListener('click', () => this.open()); 79 | } 80 | 81 | // Close button 82 | const closeBtn = this.modal.querySelector('.modal-close'); 83 | closeBtn.addEventListener('click', () => this.close()); 84 | 85 | // Backdrop click 86 | const backdrop = this.modal.querySelector('.modal-backdrop'); 87 | backdrop.addEventListener('click', () => this.close()); 88 | 89 | // ESC key 90 | document.addEventListener('keydown', (e) => { 91 | if (e.key === 'Escape' && this.isOpen) { 92 | this.close(); 93 | } 94 | }); 95 | 96 | // Handle internal navigation links 97 | this.modal.addEventListener('click', (e) => { 98 | if (e.target.matches('a[href^="#"]')) { 99 | e.preventDefault(); 100 | const targetId = e.target.getAttribute('href').substring(1); 101 | const targetElement = this.modal.querySelector(`#${targetId}`); 102 | if (targetElement) { 103 | targetElement.scrollIntoView({ behavior: 'smooth' }); 104 | } 105 | } 106 | }); 107 | } 108 | 109 | open() { 110 | if (this.isOpen) return; 111 | 112 | this.isOpen = true; 113 | this.modal.style.display = 'flex'; // Use flex to center the modal 114 | document.body.style.overflow = 'hidden'; // Prevent background scrolling 115 | 116 | // Focus management 117 | const closeBtn = this.modal.querySelector('.modal-close'); 118 | closeBtn.focus(); 119 | 120 | // Trigger custom event 121 | this.trigger.dispatchEvent(new CustomEvent('helpModal:open', { detail: this })); 122 | } 123 | 124 | close() { 125 | if (!this.isOpen) return; 126 | 127 | this.isOpen = false; 128 | this.modal.style.display = 'none'; 129 | document.body.style.overflow = ''; // Restore scrolling 130 | 131 | // Return focus to trigger 132 | this.trigger.focus(); 133 | 134 | // Trigger custom event 135 | this.trigger.dispatchEvent(new CustomEvent('helpModal:close', { detail: this })); 136 | } 137 | 138 | // Public API methods 139 | static init(options) { 140 | return new HelpModal(options); 141 | } 142 | 143 | destroy() { 144 | if (this.modal && this.modal.parentNode) { 145 | this.modal.parentNode.removeChild(this.modal); 146 | } 147 | document.body.style.overflow = ''; 148 | } 149 | 150 | // Method to update content dynamically 151 | updateContent(newContent) { 152 | const modalBody = this.modal.querySelector('.modal-body'); 153 | if (modalBody) { 154 | modalBody.innerHTML = newContent; 155 | } 156 | } 157 | } 158 | 159 | // Export for use 160 | if (typeof module !== 'undefined' && module.exports) { 161 | module.exports = HelpModal; 162 | } else { 163 | window.HelpModal = HelpModal; 164 | } 165 | -------------------------------------------------------------------------------- /client/example-app/example-app.js: -------------------------------------------------------------------------------- 1 | // example-app.js 2 | // Interactive Component Showcase Application 3 | 4 | (function() { 5 | const status = document.getElementById('status'); 6 | 7 | // App state 8 | let counterValue = 0; 9 | let incrementAmount = 1; 10 | let counterLabel = 'Counter'; 11 | let dropdownInstance = null; 12 | 13 | function setStatus(msg) { 14 | if (status) { 15 | status.textContent = msg; 16 | } 17 | } 18 | 19 | // Update counter display 20 | function updateCounterDisplay() { 21 | const counterDisplay = document.getElementById('counter-value'); 22 | const labelDisplay = document.getElementById('display-label'); 23 | const labelValueDisplay = document.getElementById('display-label-value'); 24 | const incrementValueDisplay = document.getElementById('display-increment-value'); 25 | 26 | if (counterDisplay) { 27 | counterDisplay.textContent = counterValue; 28 | } 29 | 30 | if (labelDisplay) { 31 | labelDisplay.textContent = counterLabel; 32 | } 33 | 34 | if (labelValueDisplay) { 35 | labelValueDisplay.textContent = counterLabel; 36 | } 37 | 38 | if (incrementValueDisplay) { 39 | incrementValueDisplay.textContent = incrementAmount; 40 | } 41 | 42 | // Update status tags 43 | updateStatusTags(); 44 | } 45 | 46 | // Update status tags based on counter value 47 | function updateStatusTags() { 48 | const primaryTag = document.getElementById('status-tag-primary'); 49 | const positiveTag = document.getElementById('status-tag-positive'); 50 | const negativeTag = document.getElementById('status-tag-negative'); 51 | 52 | if (counterValue > 0) { 53 | if (primaryTag) primaryTag.style.display = 'none'; 54 | if (positiveTag) positiveTag.style.display = 'inline-block'; 55 | if (negativeTag) negativeTag.style.display = 'none'; 56 | } else if (counterValue < 0) { 57 | if (primaryTag) primaryTag.style.display = 'none'; 58 | if (positiveTag) positiveTag.style.display = 'none'; 59 | if (negativeTag) negativeTag.style.display = 'inline-block'; 60 | } else { 61 | if (primaryTag) primaryTag.style.display = 'inline-block'; 62 | if (positiveTag) positiveTag.style.display = 'none'; 63 | if (negativeTag) negativeTag.style.display = 'none'; 64 | } 65 | } 66 | 67 | // Increment counter 68 | function incrementCounter() { 69 | counterValue += incrementAmount; 70 | updateCounterDisplay(); 71 | setStatus('Counter incremented'); 72 | } 73 | 74 | // Decrement counter 75 | function decrementCounter() { 76 | counterValue -= incrementAmount; 77 | updateCounterDisplay(); 78 | setStatus('Counter decremented'); 79 | } 80 | 81 | // Reset counter 82 | function resetCounter() { 83 | counterValue = 0; 84 | updateCounterDisplay(); 85 | setStatus('Counter reset'); 86 | } 87 | 88 | // Initialize dropdown component 89 | function initializeDropdown() { 90 | if (typeof window.Dropdown === 'undefined') { 91 | console.error('Dropdown class not found. Make sure dropdown.js is loaded.'); 92 | return; 93 | } 94 | 95 | const dropdownItems = [ 96 | { value: '1', label: '1' }, 97 | { value: '5', label: '5' }, 98 | { value: '10', label: '10' }, 99 | { value: '25', label: '25' } 100 | ]; 101 | 102 | try { 103 | dropdownInstance = new window.Dropdown('#increment-dropdown', { 104 | items: dropdownItems, 105 | selectedValue: '1', 106 | placeholder: 'Select increment amount', 107 | onSelect: (value) => { 108 | incrementAmount = parseInt(value, 10); 109 | updateCounterDisplay(); 110 | setStatus(`Increment amount set to ${incrementAmount}`); 111 | } 112 | }); 113 | } catch (error) { 114 | console.error('Error initializing dropdown:', error); 115 | } 116 | } 117 | 118 | // Initialize event listeners 119 | function initializeEventListeners() { 120 | // Sidebar controls 121 | const btnIncrement = document.getElementById('btn-increment'); 122 | const btnDecrement = document.getElementById('btn-decrement'); 123 | const btnReset = document.getElementById('btn-reset'); 124 | const counterLabelInput = document.getElementById('counter-label'); 125 | 126 | // Increment button 127 | if (btnIncrement) { 128 | btnIncrement.addEventListener('click', incrementCounter); 129 | } 130 | 131 | // Decrement button 132 | if (btnDecrement) { 133 | btnDecrement.addEventListener('click', decrementCounter); 134 | } 135 | 136 | // Reset button 137 | if (btnReset) { 138 | btnReset.addEventListener('click', resetCounter); 139 | } 140 | 141 | // Label input 142 | if (counterLabelInput) { 143 | counterLabelInput.addEventListener('input', (e) => { 144 | counterLabel = e.target.value || 'Counter'; 145 | updateCounterDisplay(); 146 | setStatus('Label updated'); 147 | }); 148 | } 149 | } 150 | 151 | // Initialize help modal 152 | async function initializeHelpModal() { 153 | try { 154 | const response = await fetch('./help-content.html'); 155 | const helpContent = await response.text(); 156 | 157 | if (typeof HelpModal !== 'undefined') { 158 | HelpModal.init({ 159 | triggerSelector: '#btn-help', 160 | content: helpContent, 161 | theme: 'auto' 162 | }); 163 | } else { 164 | console.error('HelpModal not found. Make sure help-modal.js is loaded.'); 165 | } 166 | } catch (error) { 167 | console.error('Failed to load help content:', error); 168 | if (typeof HelpModal !== 'undefined') { 169 | HelpModal.init({ 170 | triggerSelector: '#btn-help', 171 | content: '

Help content could not be loaded. Please check that help-content.html exists.

', 172 | theme: 'auto' 173 | }); 174 | } 175 | } 176 | } 177 | 178 | // Initialize everything when DOM is ready 179 | function initialize() { 180 | setStatus('Loading...'); 181 | 182 | // Initialize event listeners 183 | initializeEventListeners(); 184 | 185 | // Initialize help modal 186 | initializeHelpModal(); 187 | 188 | // Initialize dropdown after a short delay to ensure Dropdown class is loaded 189 | setTimeout(() => { 190 | initializeDropdown(); 191 | updateCounterDisplay(); 192 | setStatus('Ready'); 193 | }, 100); 194 | } 195 | 196 | if (document.readyState === 'loading') { 197 | document.addEventListener('DOMContentLoaded', initialize); 198 | } else { 199 | initialize(); 200 | } 201 | })(); 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bespoke Simulation Template 2 | 3 | This directory contains a template for creating embedded applications that share a consistent design system and user experience. 4 | 5 | ## Components 6 | 7 | ### 1. Design System Integration 8 | This template uses the CodeSignal Design System located in `client/design-system/`: 9 | - **Foundations**: Colors, spacing, typography tokens 10 | - **Components**: Buttons, boxes, inputs, dropdowns, tags 11 | - Light and dark theme support (automatic) 12 | - See the [design system repository](https://github.com/CodeSignal/learn_bespoke-design-system) for full documentation 13 | 14 | ### 2. `client/bespoke-template.css` 15 | Template-specific CSS providing: 16 | - Layout components (header, sidebar, main-layout) 17 | - Utility classes (row, spacer, status) 18 | - Temporary components (modals, form elements) - will be replaced when design system adds them 19 | 20 | ### 3. `client/index.html` 21 | A base HTML template that includes: 22 | - Navigation header with app name and help button 23 | - Main layout structure (sidebar + content area) 24 | - Help modal integration 25 | - Proper CSS and JavaScript loading 26 | 27 | ### 4. `client/help-modal.js` 28 | A dependency-free JavaScript module for the help modal system: 29 | - Consistent modal behavior across all apps 30 | - Keyboard navigation (ESC to close) 31 | - Focus management 32 | - Custom event system 33 | 34 | ### 5. `client/help-content-template.html` 35 | A template for creating consistent help content: 36 | - Table of contents navigation 37 | - Standardized section structure 38 | - FAQ with collapsible details 39 | - Image integration guidelines 40 | 41 | ## Usage Instructions 42 | 43 | ### Setting Up a New Application 44 | 45 | 1. **Clone the repository** 46 | 2. **Ensure the design-system submodule is initialized**: 47 | ```bash 48 | git submodule update --init --recursive 49 | ``` 50 | 51 | 3. **Customize the HTML template** by replacing placeholders: 52 | - `` - Your application title 53 | - `` - Your application name (appears in header) 54 | - `` - Any additional header elements 55 | - `` - Your main content area 56 | - `` - Links to your app-specific CSS files 57 | - `` - Links to your app-specific JavaScript files 58 | 59 | 3. **Use Design System Components** 60 | The template uses design system components directly. Use these classes: 61 | - Buttons: `button button-primary`, `button button-secondary`, `button button-danger`, `button button-text` 62 | - Boxes/Cards: `box card` for card containers 63 | - Inputs: Add `input` class to input elements: `` 64 | 65 | 4. **Implement your application logic**. You can use Cursor or other agents for it. There is a file called `AGENTS.md` that contains context LLM can use. 66 | 5. **Customise your help content** using the help content template 67 | 3. **Use Design System Components** 68 | The template uses design system components directly. Use these classes: 69 | - Buttons: `button button-primary`, `button button-secondary`, `button button-danger`, `button button-text` 70 | - Boxes/Cards: `box card` for card containers 71 | - Inputs: Add `input` class to input elements: `` 72 | 73 | 4. **Implement your application logic**. You can use Cursor or other agents for it. There is a file called `AGENTS.md` that contains context LLM can use. 74 | 5. **Customise your help content** using the help content template 75 | 76 | ### Customizing Help Content 77 | 78 | Use the `help-content-template.html` as a starting point: 79 | 80 | 1. **Replace placeholders** like `` with your actual content 81 | 2. **Add sections** as needed for your application 82 | 3. **Include images** by placing them in a `help/img/` directory 83 | 4. **Use the provided structure** for consistency across applications 84 | 85 | 86 | ### Help Modal API 87 | 88 | The `HelpModal` class provides several methods: 89 | 90 | ```javascript 91 | // Initialize 92 | const modal = HelpModal.init({ 93 | triggerSelector: '#btn-help', 94 | content: helpContent, 95 | theme: 'auto' 96 | }); 97 | 98 | // Update content dynamically 99 | modal.updateContent(newHelpContent); 100 | 101 | // Destroy the modal 102 | modal.destroy(); 103 | ``` 104 | 105 | ## Server 106 | 107 | This template includes a local development server (`server.js`) that provides: 108 | - Static file serving for your application 109 | - WebSocket support for real-time messaging 110 | - A REST API for triggering client-side alerts 111 | 112 | ### Starting the Server 113 | 114 | ```bash 115 | # Local development 116 | npm run start:dev # Vite + API for local development 117 | # Production 118 | npm run build # Create production build in dist/ 119 | npm run start:prod # Serve built assets from dist/ 120 | ``` 121 | 122 | 123 | ### Environment Variables 124 | 125 | The server supports the following environment variables: 126 | 127 | - **`PORT`** - Server port number 128 | - Development: Can be set to any port (e.g., `PORT=3001`), defaulting to `3000` 129 | - Production: Ignored (always `3000` when `IS_PRODUCTION=true`) 130 | 131 | - **`IS_PRODUCTION`** - Enables production mode 132 | - Set to `'true'` to enable production mode 133 | - When enabled: 134 | - Server serves static files from `dist/` directory 135 | - Port is forced to `3000` 136 | - Requires `dist/` directory to exist (throws error if missing) 137 | 138 | 139 | ### Vite Build System 140 | 141 | This project uses [Vite](https://vitejs.dev/) as the build tool for fast development and optimized production builds. 142 | 143 | #### Build Process 144 | 145 | Running `npm run build` executes `vite build`, which: 146 | - Reads source files from the `client/` directory (configured in `vite.config.js`) 147 | - Processes and bundles JavaScript, CSS, and other assets 148 | - Outputs optimized production files to the `dist/` directory 149 | - Generates hashed filenames for cache busting 150 | 151 | ### WebSocket Messaging API 152 | 153 | The server provides a `POST /message` endpoint that allows you to send real-time messages to connected clients. This can be used to signal changes in the client during events like "Run" or "Submit". When a message is sent, the preview window with the application open will display an alert with the message. 154 | 155 | It uses the `ws` package, so if you want to use it, install the packages (but this is optional). 156 | 157 | ``` 158 | npm install 159 | ``` 160 | 161 | #### Endpoint: `POST /message` 162 | 163 | **Request Format:** 164 | ```json 165 | { 166 | "message": "Your message here" 167 | } 168 | ``` 169 | 170 | **Example using curl:** 171 | ```bash 172 | curl -X POST http://localhost:3000/message \ 173 | -H "Content-Type: application/json" \ 174 | -d '{"message": "Hello from the server!"}' 175 | ``` 176 | 177 | ## CI/CD and Automated Releases 178 | 179 | This template includes a GitHub Actions workflow (`.github/workflows/build-release.yml`) that automatically builds and releases your application when you push to the `main` branch. 180 | 181 | ### How It Works 182 | 183 | When you push to `main`, the workflow will: 184 | 185 | 1. **Build the project** - Runs `npm run build` to create production assets in `dist/` 186 | 2. **Create a release tarball** - Packages `dist/`, `package.json`, `server.js`, and production `node_modules/` into `release.tar.gz` 187 | 3. **Create a GitHub Release** - Automatically creates a new release tagged as `v{run_number}` with the tarball attached 188 | 189 | ### Release Contents 190 | 191 | The release tarball (`release.tar.gz`) contains everything needed to deploy the application: 192 | - `dist/` - Built production assets 193 | - `package.json` - Project dependencies and scripts 194 | - `server.js` - Production server 195 | - `node_modules/` - Production dependencies only 196 | 197 | ### Using Releases 198 | 199 | To deploy a release: 200 | 201 | 1. Download `release.tar.gz` from the latest GitHub Release (e.g. with `wget`) 202 | 2. Extract (and remove) the tarball: `tar -xzf release.tar.gz && rm release.tar.gz` 203 | 3. Start the production server: `npm run start:prod` 204 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const url = require('url'); 5 | 6 | // Try to load WebSocket module, fallback if not available 7 | let WebSocket = null; 8 | let isWebSocketAvailable = false; 9 | try { 10 | WebSocket = require('ws'); 11 | isWebSocketAvailable = true; 12 | console.log('WebSocket support enabled'); 13 | } catch (error) { 14 | console.log('WebSocket support disabled (ws package not installed)'); 15 | console.log('Install with: npm install ws'); 16 | } 17 | 18 | const DIST_DIR = path.join(__dirname, 'dist'); 19 | // Check if IS_PRODUCTION is set to true 20 | const isProduction = process.env.IS_PRODUCTION === 'true'; 21 | // In production mode, dist directory must exist 22 | if (isProduction && !fs.existsSync(DIST_DIR)) { 23 | throw new Error(`Production mode enabled but dist directory does not exist: ${DIST_DIR}`); 24 | } 25 | // Force port 3000 in production, otherwise use PORT environment variable or default to 3000 26 | const PORT = isProduction ? 3000 : (process.env.PORT || 3000); 27 | 28 | // Track connected WebSocket clients 29 | const wsClients = new Set(); 30 | 31 | // MIME types for different file extensions 32 | const mimeTypes = { 33 | '.html': 'text/html', 34 | '.js': 'text/javascript', 35 | '.css': 'text/css', 36 | '.json': 'application/json', 37 | '.png': 'image/png', 38 | '.jpg': 'image/jpeg', 39 | '.jpeg': 'image/jpeg', 40 | '.gif': 'image/gif', 41 | '.svg': 'image/svg+xml', 42 | '.ico': 'image/x-icon', 43 | '.woff': 'font/woff', 44 | '.woff2': 'font/woff2', 45 | '.ttf': 'font/ttf', 46 | '.eot': 'application/vnd.ms-fontobject' 47 | }; 48 | 49 | // Get MIME type based on file extension 50 | function getMimeType(filePath) { 51 | const ext = path.extname(filePath).toLowerCase(); 52 | return mimeTypes[ext] || 'text/plain'; 53 | } 54 | 55 | // Serve static files 56 | function serveFile(filePath, res) { 57 | fs.readFile(filePath, (err, data) => { 58 | if (err) { 59 | res.writeHead(404, { 'Content-Type': 'text/plain' }); 60 | res.end('File not found'); 61 | return; 62 | } 63 | 64 | const mimeType = getMimeType(filePath); 65 | res.writeHead(200, { 'Content-Type': mimeType }); 66 | res.end(data); 67 | }); 68 | } 69 | 70 | // Handle POST requests 71 | function handlePostRequest(req, res, parsedUrl) { 72 | if (parsedUrl.pathname === '/message') { 73 | let body = ''; 74 | 75 | req.on('data', chunk => { 76 | body += chunk.toString(); 77 | }); 78 | 79 | req.on('end', () => { 80 | try { 81 | const data = JSON.parse(body); 82 | const message = data.message; 83 | 84 | if (!message) { 85 | res.writeHead(400, { 'Content-Type': 'application/json' }); 86 | res.end(JSON.stringify({ error: 'Message is required' })); 87 | return; 88 | } 89 | 90 | // Check if WebSocket is available 91 | if (!isWebSocketAvailable) { 92 | res.writeHead(503, { 'Content-Type': 'application/json' }); 93 | res.end(JSON.stringify({ 94 | error: 'WebSocket functionality not available', 95 | details: 'Install the ws package with: npm install ws' 96 | })); 97 | return; 98 | } 99 | 100 | // Broadcast message to all connected WebSocket clients 101 | wsClients.forEach(client => { 102 | if (client.readyState === WebSocket.OPEN) { 103 | client.send(JSON.stringify({ type: 'message', message: message })); 104 | } 105 | }); 106 | 107 | res.writeHead(200, { 'Content-Type': 'application/json' }); 108 | res.end(JSON.stringify({ success: true, clientCount: wsClients.size })); 109 | 110 | } catch (error) { 111 | res.writeHead(400, { 'Content-Type': 'application/json' }); 112 | res.end(JSON.stringify({ error: 'Invalid JSON' })); 113 | } 114 | }); 115 | } else if (parsedUrl.pathname === '/save-stats') { 116 | let body = ''; 117 | 118 | req.on('data', chunk => { 119 | body += chunk.toString(); 120 | }); 121 | 122 | req.on('end', () => { 123 | try { 124 | const clientDir = path.join(__dirname, 'client'); 125 | const statsPath = path.join(clientDir, 'stats.txt'); 126 | 127 | // Ensure client directory exists 128 | if (!fs.existsSync(clientDir)) { 129 | fs.mkdirSync(clientDir, { recursive: true }); 130 | } 131 | 132 | // Write stats to file 133 | fs.writeFileSync(statsPath, body, 'utf8'); 134 | 135 | res.writeHead(200, { 'Content-Type': 'application/json' }); 136 | res.end(JSON.stringify({ success: true, message: 'Statistics saved' })); 137 | 138 | } catch (error) { 139 | console.error('Error saving statistics:', error); 140 | res.writeHead(500, { 'Content-Type': 'application/json' }); 141 | res.end(JSON.stringify({ error: 'Failed to save statistics' })); 142 | } 143 | }); 144 | } else { 145 | res.writeHead(404, { 'Content-Type': 'text/plain' }); 146 | res.end('Not found'); 147 | } 148 | } 149 | 150 | // Create HTTP server 151 | const server = http.createServer((req, res) => { 152 | const parsedUrl = url.parse(req.url, true); 153 | let pathName = parsedUrl.pathname === '/' ? '/index.html' : parsedUrl.pathname; 154 | 155 | // Handle POST requests 156 | if (req.method === 'POST') { 157 | handlePostRequest(req, res, parsedUrl); 158 | return; 159 | } 160 | 161 | // In production mode, serve static files from dist directory 162 | if (isProduction) { 163 | // Strip leading slashes so path.join/resolve can't ignore DIST_DIR 164 | let filePath = path.join(DIST_DIR, pathName.replace(/^\/+/, '')); 165 | 166 | // Security check - prevent directory traversal 167 | const resolvedDistDir = path.resolve(DIST_DIR); 168 | const resolvedFilePath = path.resolve(filePath); 169 | const relativePath = path.relative(resolvedDistDir, resolvedFilePath); 170 | 171 | // Reject if path tries to traverse outside the base directory 172 | if (relativePath.startsWith('..')) { 173 | res.writeHead(403, { 'Content-Type': 'text/plain' }); 174 | res.end('Forbidden'); 175 | return; 176 | } 177 | 178 | serveFile(filePath, res); 179 | } else { 180 | // Development mode - static files are served by Vite 181 | res.writeHead(404, { 'Content-Type': 'text/plain' }); 182 | res.end('Not found (development mode - use Vite dev server `npm run start:dev`)'); 183 | } 184 | }); 185 | 186 | // Create WebSocket server only if WebSocket is available 187 | // Note: WebSocket upgrade handling is performed automatically by the ws library 188 | // when attached to the HTTP server. The HTTP request handler should NOT send 189 | // a response for upgrade requests - the ws library handles the upgrade internally. 190 | if (isWebSocketAvailable) { 191 | const wss = new WebSocket.Server({ 192 | server, 193 | path: '/ws' 194 | }); 195 | 196 | wss.on('connection', (ws, req) => { 197 | console.log('New WebSocket client connected'); 198 | wsClients.add(ws); 199 | 200 | ws.on('close', () => { 201 | console.log('WebSocket client disconnected'); 202 | wsClients.delete(ws); 203 | }); 204 | 205 | ws.on('error', (error) => { 206 | console.error('WebSocket error:', error); 207 | wsClients.delete(ws); 208 | }); 209 | }); 210 | } 211 | 212 | // Start server 213 | server.listen(PORT, () => { 214 | console.log(`Server running at http://localhost:${PORT}`); 215 | if (isProduction) { 216 | console.log(`Serving static files from: ${DIST_DIR}`); 217 | } else { 218 | console.log(`Development mode - static files served by Vite`); 219 | } 220 | if (isWebSocketAvailable) { 221 | console.log(`WebSocket server running on /ws`); 222 | } else { 223 | console.log(`WebSocket functionality disabled - install 'ws' package to enable`); 224 | } 225 | console.log('Press Ctrl+C to stop the server'); 226 | }); 227 | 228 | // Handle server errors 229 | server.on('error', (err) => { 230 | if (err.code === 'EADDRINUSE') { 231 | console.error(`Port ${PORT} is already in use. Please try a different port.`); 232 | } else { 233 | console.error('Server error:', err); 234 | } 235 | process.exit(1); 236 | }); 237 | 238 | // Graceful shutdown 239 | process.on('SIGINT', () => { 240 | console.log('\nShutting down server...'); 241 | server.close(() => { 242 | console.log('Server closed'); 243 | process.exit(0); 244 | }); 245 | }); 246 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Typing Simulator 6 | 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 |

Typing Simulator

32 |
33 | 34 | 35 |
36 |
37 |
38 | 39 | 87 | 88 | 119 | 120 |
121 |
122 |
123 | 124 | 132 |
133 |

Completed

134 |

Great job! You've finished typing the text.

135 | 136 |
137 |
138 |
139 |

Typing Statistics

140 |

Your performance summary

141 |
142 |
143 |
144 |
145 | 0 146 |
147 |
WPM
148 |
149 |
150 |
151 | 0% 152 |
153 |
Accuracy
154 |
155 |
156 |
157 | 0s 158 |
159 |
Total time
160 |
161 |
162 |
163 | 0 164 |
165 |
Total errors
166 |
167 |
168 |
169 | 0 170 |
171 |
Errors left
172 |
173 |
174 | 177 |
178 |
179 | 186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 206 | 207 | 208 | -------------------------------------------------------------------------------- /BESPOKE-TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Bespoke Simulation Template 2 | 3 | This document provides precise implementation instructions for creating 4 | embedded applications using the Bespoke Simulation template. Follow these 5 | instructions exactly to ensure consistency across all applications. 6 | NOTE: Never edit this `BESPOKE-TEMPLATE.md` file. Codebase changes should be reflected in the `AGENTS.md` file. 7 | 8 | ## Required Files Structure 9 | 10 | Every application should include these files in the following order: 11 | 12 | 1. CodeSignal Design System foundations: 13 | - colors/colors.css 14 | - spacing/spacing.css 15 | - typography/typography.css 16 | - components/button/button.css (used in header) 17 | 2. CodeSignal Design System components (optional): 18 | - components/boxes/boxes.css 19 | - components/dropdown/dropdown.css 20 | - components/input/input.css 21 | - components/tags/tags.css 22 | 3. bespoke-template.css (template-specific layout, utilities, temporary 23 | components) 24 | 4. help-modal.js (help system) 25 | 5. app.js (application logic) 26 | 6. server.js (server) 27 | 28 | ## HTML Template Implementation 29 | 30 | 1. REPLACE the following placeholders in index.html EXACTLY as specified: 31 | 32 | a) `` 33 | Replace with your application's page title 34 | Example: "Database Designer" or "Task Manager" 35 | 36 | b) `` 37 | Replace with your application's display name (appears in header) 38 | Example: "Database Designer" or "Task Manager" 39 | 40 | c) `` 41 | Add your application's main content area 42 | Example: `
` or `
` 43 | 44 | d) `` 45 | Add links to your application-specific CSS files 46 | Example: `` 47 | 48 | e) `` 49 | Add links to your application-specific JavaScript files 50 | Example: `` 51 | 52 | 2. DO NOT modify the core structure (header, script loading order, etc.) 53 | 54 | ## CSS Implementation 55 | 56 | 1. ALWAYS use the `.bespoke` class on the body element for scoping 57 | 2. USE design system components directly with proper classes: 58 | - Buttons: `button button-primary`, `button button-secondary`, 59 | `button button-danger`, `button button-text` 60 | - Boxes/Cards: `box card` for card containers 61 | - Inputs: Add `input` class to input elements: 62 | `` 63 | 3. USE design system CSS custom properties for styling: 64 | - Colors: `--Colors-*` (e.g., `--Colors-Primary-Default`, 65 | `--Colors-Text-Body-Default`) 66 | - Spacing: `--UI-Spacing-*` (e.g., `--UI-Spacing-spacing-ml`, 67 | `--UI-Spacing-spacing-xl`) 68 | - Typography: `--Fonts-*` (e.g., `--Fonts-Body-Default-md`, 69 | `--Fonts-Headlines-sm`) 70 | - Borders: `--UI-Radius-*` (e.g., `--UI-Radius-radius-s`, 71 | `--UI-Radius-radius-m`) 72 | - Font families: `--body-family`, `--heading-family` 73 | 4. FOR custom styling, create app-specific CSS files 74 | 5. OVERRIDE design system variables in your app-specific CSS, not in 75 | bespoke-template.css 76 | 6. FOLLOW design system naming conventions for consistency 77 | 78 | ## JavaScript Implementation 79 | 80 | 1. HELP MODAL SETUP: 81 | a) Create help content using help-content-template.html as reference 82 | b) Initialize HelpModal with: 83 | - triggerSelector: `'#btn-help'` 84 | - content: your help content (string or loaded from file) 85 | - theme: `'auto'` 86 | 87 | 2. STATUS MANAGEMENT: 88 | a) Use the provided setStatus() function for status updates 89 | b) Update status for: loading, saving, errors, user actions 90 | c) Keep status messages concise and informative 91 | 92 | ## Error Handling Requirements 93 | 94 | 1. WRAP all async operations in try-catch blocks 95 | 2. PROVIDE meaningful error messages to users 96 | 3. LOG errors to console for debugging 97 | 4. IMPLEMENT retry logic for network operations 98 | 5. HANDLE localStorage quota exceeded errors 99 | 6. VALIDATE data before saving operations 100 | 101 | ## Status Message Conventions 102 | 103 | Use these EXACT status messages for consistency: 104 | 105 | - "Ready" - Application loaded successfully 106 | - "Loading..." - Data is being loaded 107 | - "Saving..." - Data is being saved 108 | - "Changes saved" - Auto-save completed successfully 109 | - "Save failed (will retry)" - Server save failed, will retry 110 | - "Failed to load data" - Data loading failed 111 | - "Auto-save initialized" - Auto-save system started 112 | 113 | ## File Naming Conventions 114 | 115 | 1. CSS files: kebab-case (e.g., my-app.css, task-manager.css) 116 | 2. JavaScript files: kebab-case (e.g., my-app.js, task-manager.js) 117 | 3. Data files: kebab-case (e.g., solution.json, initial-data.json) 118 | 4. Image files: kebab-case (e.g., overview.png, help-icon.svg) 119 | 120 | --- 121 | 122 | # Bespoke Template Design System Guidelines 123 | 124 | This section explains how to use the CodeSignal Design System with the 125 | Bespoke template for embedded applications. 126 | 127 | ## Overview 128 | 129 | The Bespoke template uses the CodeSignal Design System for components and 130 | tokens, with template-specific layout and utilities. All styles are scoped 131 | under the `.bespoke` class to prevent interference with parent site styles. 132 | The template uses design system components directly where available, and 133 | provides temporary components (modals, form elements) that will be replaced 134 | when the design system adds them. 135 | 136 | ## Basic Usage 137 | 138 | ### 1. Include the CSS 139 | 140 | ```html 141 | 142 | ``` 143 | 144 | ### 2. Wrap Your Application 145 | 146 | ```html 147 |
148 | 149 |
150 | ``` 151 | 152 | ### 3. Use the Component Classes 153 | 154 | ```html 155 |
156 |
157 |

My App

158 |
Ready
159 | 160 |
161 | 162 |
163 | 174 | 175 |
176 | 177 |
178 |
179 |
180 | ``` 181 | 182 | ## Component Reference 183 | 184 | ### Layout Components 185 | 186 | #### Header 187 | 188 | ```html 189 |
190 |

App Title

191 |
Status message
192 | 193 |
194 | ``` 195 | 196 | #### Main Layout (Sidebar + Content) 197 | 198 | ```html 199 |
200 | 203 |
204 | 205 |
206 |
207 | ``` 208 | 209 | #### Cards 210 | 211 | ```html 212 |
213 |

Card Title

214 |

Subtitle

215 |

Card content goes here

216 |
217 | ``` 218 | 219 | ### Form Components 220 | 221 | #### Labels 222 | 223 | ```html 224 | 225 | 228 | 229 | 230 | 234 | ``` 235 | 236 | #### Input Fields 237 | 238 | ```html 239 | 240 | 241 | 242 | 243 | 247 | 248 | 249 | 250 | 251 | 252 |
253 | 257 | 261 |
262 | 263 | 264 |
265 | 269 | 273 |
274 | 275 | 276 | 277 | 278 | 279 | 286 | ``` 287 | 288 | #### Buttons 289 | 290 | ```html 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | Link Button 301 | ``` 302 | 303 | ### Modal Components 304 | 305 | #### Basic Modal 306 | 307 | ```html 308 | 320 | ``` 321 | 322 | ## Customization 323 | 324 | ### CSS Custom Properties 325 | 326 | You can override any CSS custom property to customize the appearance: 327 | 328 | ```css 329 | .bespoke { 330 | /* Override colors */ 331 | --bespoke-bg: #f0f0f0; 332 | --bespoke-fg: #333333; 333 | --bespoke-accent: #ff6b6b; 334 | 335 | /* Override spacing */ 336 | --bespoke-space-lg: 1.5rem; 337 | 338 | /* Override border radius */ 339 | --bespoke-radius-lg: 12px; 340 | } 341 | ``` 342 | 343 | ### Available Custom Properties 344 | 345 | #### Colors 346 | 347 | - `--bespoke-bg`: Background color 348 | - `--bespoke-fg`: Text color 349 | - `--bespoke-muted`: Muted text color 350 | - `--bespoke-box`: Container/surface background 351 | - `--bespoke-stroke`: Border color 352 | - `--bespoke-danger`: Error/danger color 353 | - `--bespoke-accent`: Accent/primary color 354 | - `--bespoke-control-bg`: Input/button background 355 | - `--bespoke-control-border`: Input/button border 356 | - `--bespoke-control-focus`: Focus ring color 357 | 358 | #### Spacing 359 | 360 | - `--bespoke-space-xs`: 0.25rem 361 | - `--bespoke-space-sm`: 0.5rem 362 | - `--bespoke-space-md`: 0.75rem 363 | - `--bespoke-space-lg`: 1rem 364 | - `--bespoke-space-xl`: 1.5rem 365 | - `--bespoke-space-2xl`: 2rem 366 | 367 | #### Border Radius 368 | 369 | - `--bespoke-radius-sm`: 4px 370 | - `--bespoke-radius-md`: 6px 371 | - `--bespoke-radius-lg`: 8px 372 | - `--bespoke-radius-xl`: 12px 373 | 374 | #### Shadows 375 | 376 | - `--bespoke-shadow-sm`: Small shadow 377 | - `--bespoke-shadow-md`: Medium shadow 378 | - `--bespoke-shadow-lg`: Large shadow 379 | - `--bespoke-shadow-xl`: Extra large shadow 380 | 381 | ## Theme Support 382 | 383 | ### Automatic Dark Mode 384 | 385 | The framework automatically detects the user's system preference and switches 386 | between light and dark themes. No additional configuration is needed. 387 | 388 | ## Integration Examples 389 | 390 | ### Database Designer 391 | 392 | ```html 393 |
394 |
395 |

DB Schema Designer

396 | 397 |
Ready
398 | 399 |
400 | 401 |
402 | 413 | 414 |
415 | 416 |
417 |
418 |
419 | ``` 420 | 421 | ## Best Practices 422 | 423 | 1. **Always wrap in `.bespoke`**: This prevents style conflicts with the parent 424 | site 425 | 2. **Use design system components directly**: Use proper class combinations like 426 | `button button-primary` 427 | 3. **Use semantic HTML**: Combine with proper HTML elements for accessibility 428 | 4. **Customize via design system CSS variables**: Override design system 429 | variables in your app-specific CSS 430 | 5. **Test in both themes**: Ensure your app works in light and dark modes 431 | 6. **Note on temporary components**: Modal and form components in 432 | `bespoke-template.css` are temporary and will be replaced when the design 433 | system adds them 434 | 435 | -------------------------------------------------------------------------------- /client/bespoke-template.css: -------------------------------------------------------------------------------- 1 | /* ===== BESPOKE TEMPLATE CSS ===== */ 2 | /* Template-specific components using CodeSignal Design System tokens */ 3 | /* This file provides layout, utilities, and temporary components not yet in the design system */ 4 | 5 | /* ===== GLOBAL STYLES ===== */ 6 | 7 | /* Ensure page never scrolls */ 8 | html, body { 9 | overflow: hidden; 10 | height: 100%; 11 | margin: 0; 12 | padding: 0; 13 | } 14 | 15 | /* ===== LAYOUT COMPONENTS ===== */ 16 | 17 | /* Bespoke wrapper for scoping */ 18 | .bespoke { 19 | font-family: var(--body-family); 20 | color: var(--Colors-Text-Body-Default); 21 | background: var(--Colors-Backgrounds-Main-Default); 22 | line-height: 1.6; 23 | margin: 0; 24 | padding: 0; 25 | display: flex; 26 | flex-direction: column; 27 | height: 100vh; 28 | overflow: hidden; 29 | } 30 | 31 | .bespoke * { 32 | box-sizing: border-box; 33 | } 34 | 35 | /* Header */ 36 | .bespoke .header { 37 | display: flex; 38 | align-items: center; 39 | gap: var(--UI-Spacing-spacing-ml); 40 | padding: var(--UI-Spacing-spacing-s) var(--UI-Spacing-spacing-mxl); 41 | border-bottom: 1px solid var(--Colors-Stroke-Default); 42 | background: var(--Colors-Backgrounds-Main-Top); 43 | width: 100%; 44 | flex: 0 0 auto; /* Don't grow or shrink */ 45 | } 46 | 47 | .bespoke .header h1 { 48 | font-size: var(--Fonts-Body-Default-lg); 49 | margin: 0; 50 | font-weight: 600; 51 | font-family: var(--heading-family); 52 | color: var(--Colors-Text-Body-Strongest); 53 | } 54 | 55 | .bespoke .header .status { 56 | font-size: var(--Fonts-Body-Default-xs); 57 | color: var(--Colors-Text-Body-Medium); 58 | } 59 | 60 | /* Main Layout */ 61 | .bespoke .main-layout { 62 | display: grid; 63 | grid-template-columns: 300px 1fr; 64 | flex: 1 1 auto; /* Take remaining space */ 65 | overflow: hidden; 66 | min-height: 0; 67 | } 68 | 69 | .bespoke .sidebar { 70 | padding: var(--UI-Spacing-spacing-s); 71 | overflow: auto; 72 | border-right: 1px solid var(--Colors-Stroke-Default); 73 | background: var(--Colors-Backgrounds-Main-Default); 74 | } 75 | 76 | .bespoke .content-area { 77 | width: 100%; 78 | height: 100%; 79 | overflow: hidden; 80 | min-height: 0; 81 | } 82 | 83 | /* ===== UTILITY CLASSES ===== */ 84 | 85 | /* Flexbox Utilities */ 86 | .bespoke .row { 87 | display: flex; 88 | align-items: center; 89 | gap: var(--UI-Spacing-spacing-s); 90 | } 91 | 92 | .bespoke .row-between { 93 | display: flex; 94 | align-items: center; 95 | justify-content: space-between; 96 | gap: var(--UI-Spacing-spacing-ml); 97 | } 98 | 99 | .bespoke .spacer { 100 | flex: 1; 101 | } 102 | 103 | /* Dividers */ 104 | .bespoke hr { 105 | border: none; 106 | border-top: 0.5px solid var(--Colors-Stroke-Default); 107 | margin: var(--UI-Spacing-spacing-ml) 0; 108 | } 109 | 110 | /* ===== TEMPORARY COMPONENTS (TODO: Replace when design system adds these) ===== */ 111 | 112 | /* Modal Components - TODO: Remove when design system adds modal component */ 113 | .bespoke .modal { 114 | position: fixed; 115 | top: 0; 116 | left: 0; 117 | width: 100%; 118 | height: 100%; 119 | z-index: 500; 120 | display: flex; 121 | align-items: center; 122 | justify-content: center; 123 | padding: var(--UI-Spacing-spacing-xl); 124 | box-sizing: border-box; 125 | margin: 0; 126 | } 127 | 128 | .bespoke .modal-backdrop { 129 | position: absolute; 130 | top: 0; 131 | left: 0; 132 | width: 100%; 133 | height: 100%; 134 | background: rgba(0, 0, 0, 0.5); 135 | backdrop-filter: blur(2px); 136 | } 137 | 138 | .bespoke .modal-content { 139 | position: relative; 140 | background: var(--Colors-Backgrounds-Main-Top); 141 | border: 1px solid var(--Colors-Stroke-Default); 142 | border-radius: var(--UI-Radius-radius-m); 143 | max-width: 800px; 144 | width: calc(100% - 40px); 145 | max-height: 90vh; 146 | display: flex; 147 | flex-direction: column; 148 | box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); 149 | margin: 0; 150 | } 151 | 152 | .bespoke .modal-header { 153 | display: flex; 154 | align-items: center; 155 | justify-content: space-between; 156 | padding: var(--UI-Spacing-spacing-xl); 157 | border-bottom: 1px solid var(--Colors-Stroke-Default); 158 | background: var(--Colors-Backgrounds-Main-Top); 159 | border-radius: var(--UI-Radius-radius-m) var(--UI-Radius-radius-m) 0 0; 160 | } 161 | 162 | .bespoke .modal-header h2 { 163 | margin: 0; 164 | font-size: var(--Fonts-Body-Default-xl); 165 | color: var(--Colors-Text-Body-Strongest); 166 | font-family: var(--heading-family); 167 | font-weight: 500; 168 | } 169 | 170 | .bespoke .modal-close { 171 | background: none; 172 | border: none; 173 | font-size: var(--Fonts-Body-Default-xxxl); 174 | color: var(--Colors-Text-Body-Medium); 175 | cursor: pointer; 176 | padding: var(--UI-Spacing-spacing-xxs) var(--UI-Spacing-spacing-s); 177 | border-radius: var(--UI-Radius-radius-xxs); 178 | line-height: 1; 179 | transition: all 0.2s ease; 180 | } 181 | 182 | .bespoke .modal-close:hover { 183 | background: var(--Colors-Backgrounds-Main-Medium); 184 | color: var(--Colors-Text-Body-Default); 185 | } 186 | 187 | .bespoke .modal-body { 188 | padding: var(--UI-Spacing-spacing-xl); 189 | overflow-y: auto; 190 | flex: 1; 191 | line-height: 1.6; 192 | } 193 | 194 | .bespoke .modal-body h2 { 195 | margin-top: var(--UI-Spacing-spacing-xxxl); 196 | margin-bottom: var(--UI-Spacing-spacing-ml); 197 | font-size: var(--Fonts-Body-Default-xl); 198 | color: var(--Colors-Text-Body-Strongest); 199 | font-family: var(--heading-family); 200 | font-weight: 500; 201 | } 202 | 203 | .bespoke .modal-body h2:first-child { 204 | margin-top: 0; 205 | } 206 | 207 | .bespoke .modal-body h3 { 208 | margin-top: var(--UI-Spacing-spacing-xl); 209 | margin-bottom: var(--UI-Spacing-spacing-s); 210 | font-size: var(--Fonts-Body-Default-lg); 211 | color: var(--Colors-Text-Body-Strongest); 212 | font-family: var(--heading-family); 213 | font-weight: 500; 214 | } 215 | 216 | .bespoke .modal-body p, 217 | .bespoke .modal-body li { 218 | color: var(--Colors-Text-Body-Default); 219 | margin-bottom: var(--UI-Spacing-spacing-s); 220 | } 221 | 222 | .bespoke .modal-body ul, 223 | .bespoke .modal-body ol { 224 | margin: var(--UI-Spacing-spacing-s) 0 var(--UI-Spacing-spacing-ml) 0; 225 | padding-left: var(--UI-Spacing-spacing-xl); 226 | } 227 | 228 | .bespoke .modal-body code { 229 | font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; 230 | background: rgba(148, 163, 184, 0.12); 231 | border-radius: var(--UI-Radius-radius-xxs); 232 | padding: 0.15em 0.35em; 233 | font-size: var(--Fonts-Body-Default-xs); 234 | } 235 | 236 | .bespoke .modal-body pre { 237 | font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; 238 | background: rgba(148, 163, 184, 0.12); 239 | border-radius: var(--UI-Radius-radius-xs); 240 | padding: var(--UI-Spacing-spacing-ms); 241 | overflow: auto; 242 | margin: var(--UI-Spacing-spacing-ml) 0; 243 | } 244 | 245 | .bespoke .modal-body img, 246 | .bespoke .modal-body video { 247 | max-width: 100%; 248 | height: auto; 249 | border-radius: var(--UI-Radius-radius-xs); 250 | border: 1px solid var(--Colors-Stroke-Default); 251 | margin: var(--UI-Spacing-spacing-ml) 0; 252 | } 253 | 254 | /* Form Elements - TODO: Remove when design system adds form components */ 255 | .bespoke label { 256 | display: flex; 257 | flex-direction: column; 258 | gap: var(--UI-Spacing-spacing-xxs); 259 | margin: var(--UI-Spacing-spacing-ms) 0 var(--UI-Spacing-spacing-s) 0; 260 | } 261 | 262 | .bespoke label.row { 263 | flex-direction: row; 264 | align-items: center; 265 | gap: var(--UI-Spacing-spacing-s); 266 | margin: var(--UI-Spacing-spacing-s) 0; 267 | } 268 | 269 | /* Textarea */ 270 | .bespoke textarea { 271 | padding: var(--UI-Spacing-spacing-ms); 272 | border: 1px solid var(--Colors-Input-Border-Default); 273 | border-radius: var(--UI-Radius-radius-s); 274 | background: var(--Colors-Input-Background-Default); 275 | color: var(--Colors-Input-Text-Default); 276 | font-family: var(--body-family); 277 | font-size: var(--Fonts-Body-Default-md); 278 | min-height: 6rem; 279 | resize: vertical; 280 | transition: border-color 0.2s ease; 281 | } 282 | 283 | .bespoke textarea:hover { 284 | border-color: var(--Colors-Input-Border-Hover); 285 | } 286 | 287 | .bespoke textarea:focus-visible { 288 | outline: none; 289 | border-color: var(--Colors-Input-Border-Focus); 290 | box-shadow: 0 0 0 4px var(--Colors-Input-Shadow-Focus); 291 | } 292 | 293 | .bespoke textarea::placeholder { 294 | color: var(--Colors-Input-Text-Placeholder); 295 | opacity: 1; 296 | } 297 | 298 | /* Select styling - TODO: Remove when design system adds select component */ 299 | .bespoke select { 300 | -webkit-appearance: none; 301 | -moz-appearance: none; 302 | appearance: none; 303 | background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6,9 12,15 18,9'%3e%3c/polyline%3e%3c/svg%3e"); 304 | background-repeat: no-repeat; 305 | background-position: right 0.75rem center; 306 | background-size: 1rem; 307 | padding-right: 3rem; 308 | } 309 | 310 | .bespoke select::-ms-expand { 311 | display: none; 312 | } 313 | 314 | /* Radio Buttons - TODO: Remove when design system adds radio component */ 315 | .bespoke input[type="radio"] { 316 | appearance: none; 317 | width: 1rem; 318 | height: 1rem; 319 | border: 2px solid var(--Colors-Input-Border-Default); 320 | border-radius: 50%; 321 | background: var(--Colors-Input-Background-Default); 322 | cursor: pointer; 323 | position: relative; 324 | transition: all 0.2s ease; 325 | flex-shrink: 0; 326 | padding: 0; 327 | } 328 | 329 | .bespoke input[type="radio"]:checked { 330 | border-color: var(--Colors-Stroke-Primary); 331 | background: var(--Colors-Stroke-Primary); 332 | } 333 | 334 | .bespoke input[type="radio"]:checked::after { 335 | content: ''; 336 | position: absolute; 337 | top: 50%; 338 | left: 50%; 339 | transform: translate(-50%, -50%); 340 | width: 0.375rem; 341 | height: 0.375rem; 342 | border-radius: 50%; 343 | background: white; 344 | } 345 | 346 | .bespoke input[type="radio"]:hover { 347 | border-color: var(--Colors-Input-Border-Hover); 348 | } 349 | 350 | .bespoke input[type="radio"]:focus-visible { 351 | outline: none; 352 | border-color: var(--Colors-Input-Border-Focus); 353 | box-shadow: 0 0 0 3px var(--Colors-Input-Shadow-Focus); 354 | } 355 | 356 | .bespoke .radio-group { 357 | display: flex; 358 | flex-direction: column; 359 | gap: var(--UI-Spacing-spacing-s); 360 | } 361 | 362 | .bespoke .radio-group.horizontal { 363 | flex-direction: row; 364 | align-items: center; 365 | gap: var(--UI-Spacing-spacing-ml); 366 | } 367 | 368 | /* Checkbox - TODO: Remove when design system adds checkbox component */ 369 | .bespoke input[type="checkbox"] { 370 | padding: 0; 371 | margin: 0; 372 | } 373 | 374 | /* Toggle Switch - TODO: Remove when design system adds toggle component */ 375 | .bespoke .toggle { 376 | position: relative; 377 | display: inline-block; 378 | width: 3rem; 379 | height: 1.5rem; 380 | } 381 | 382 | .bespoke .toggle-input { 383 | opacity: 0; 384 | width: 0; 385 | height: 0; 386 | } 387 | 388 | .bespoke .toggle-slider { 389 | position: absolute; 390 | cursor: pointer; 391 | top: 0; 392 | left: 0; 393 | right: 0; 394 | bottom: 0; 395 | background-color: var(--Colors-Stroke-Medium); 396 | transition: 0.3s; 397 | border-radius: 1.5rem; 398 | } 399 | 400 | .bespoke .toggle-slider:before { 401 | position: absolute; 402 | content: ""; 403 | height: 1.125rem; 404 | width: 1.125rem; 405 | left: 0.1875rem; 406 | bottom: 0.1875rem; 407 | background-color: white; 408 | transition: 0.3s; 409 | border-radius: 50%; 410 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); 411 | } 412 | 413 | .bespoke .toggle-input:checked + .toggle-slider { 414 | background-color: var(--Colors-Primary-Default); 415 | } 416 | 417 | .bespoke .toggle-input:checked + .toggle-slider:before { 418 | transform: translateX(1.5rem); 419 | } 420 | 421 | .bespoke .toggle-input:focus + .toggle-slider { 422 | box-shadow: 0 0 0 3px var(--Colors-Input-Shadow-Focus); 423 | } 424 | 425 | .bespoke .toggle-input:disabled + .toggle-slider { 426 | opacity: 0.5; 427 | cursor: not-allowed; 428 | } 429 | 430 | .bespoke .toggle-label { 431 | margin-left: var(--UI-Spacing-spacing-s); 432 | font-size: var(--Fonts-Body-Default-xs); 433 | color: var(--Colors-Text-Body-Default); 434 | cursor: pointer; 435 | } 436 | 437 | /* Dark mode adjustments */ 438 | @media (prefers-color-scheme: dark) { 439 | .bespoke .modal-backdrop { 440 | background: rgba(0, 0, 0, 0.7); 441 | } 442 | 443 | .bespoke .modal-body code, 444 | .bespoke .modal-body pre { 445 | background: rgba(148, 163, 184, 0.2); 446 | } 447 | 448 | .bespoke select { 449 | background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23c1c7d7' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6,9 12,15 18,9'%3e%3c/polyline%3e%3c/svg%3e"); 450 | } 451 | } 452 | 453 | /* Responsive Design */ 454 | @media (max-width: 768px) { 455 | .bespoke .header { 456 | padding: var(--UI-Spacing-spacing-xs) var(--UI-Spacing-spacing-lg); 457 | gap: var(--UI-Spacing-spacing-md); 458 | } 459 | 460 | .bespoke .header h1 { 461 | font-size: var(--Fonts-Body-Default-md); 462 | } 463 | 464 | .bespoke .header .status { 465 | font-size: var(--Fonts-Body-Default-xxs); 466 | } 467 | 468 | .bespoke .main-layout { 469 | grid-template-columns: 1fr; 470 | grid-template-rows: auto 1fr; 471 | } 472 | 473 | .bespoke .sidebar { 474 | border-right: none; 475 | border-bottom: 1px solid var(--Colors-Stroke-Default); 476 | } 477 | 478 | .bespoke .modal { 479 | padding: var(--UI-Spacing-spacing-s); 480 | } 481 | 482 | .bespoke .modal-content { 483 | max-height: 95vh; 484 | } 485 | 486 | .bespoke .modal-header { 487 | padding: var(--UI-Spacing-spacing-mxl); 488 | } 489 | 490 | .bespoke .modal-body { 491 | padding: var(--UI-Spacing-spacing-mxl); 492 | } 493 | 494 | .bespoke .modal-header h2 { 495 | font-size: var(--Fonts-Body-Default-lg); 496 | } 497 | } 498 | -------------------------------------------------------------------------------- /client/typing-simulator.css: -------------------------------------------------------------------------------- 1 | /* typing-simulator.css */ 2 | 3 | /* Override main-layout grid to use full width (no sidebar in typing simulator) */ 4 | .bespoke .main-layout { 5 | grid-template-columns: 1fr; 6 | overflow: hidden; /* Prevent scrolling */ 7 | } 8 | 9 | /* Racing Track Game Styles */ 10 | .bespoke .racing-track-container { 11 | flex: 1 1 auto; 12 | position: relative; 13 | /* Remove width: 100% - let it be auto-width so it can be centered */ 14 | min-height: 0; 15 | display: flex; 16 | flex-direction: column; 17 | align-items: center; /* Center the track horizontally */ 18 | justify-content: flex-start; 19 | margin-top: var(--UI-Spacing-spacing-lg); 20 | } 21 | 22 | .bespoke .racing-track { 23 | position: relative; 24 | width: auto; /* Width will be set dynamically via JavaScript */ 25 | min-width: 70px; /* Minimum width to match text start position */ 26 | height: 300px; 27 | background: linear-gradient(to bottom, 28 | var(--Colors-Box-Background-Secondary) 0%, 29 | var(--Colors-Box-Background-Secondary) 48%, 30 | #FFFFFF 48%, 31 | #FFFFFF 52%, 32 | var(--Colors-Box-Background-Secondary) 52%, 33 | var(--Colors-Box-Background-Secondary) 100% 34 | ); 35 | border: 2px solid var(--Colors-Stroke-Default); 36 | border-radius: var(--UI-Radius-radius-m); 37 | overflow: hidden; 38 | margin-bottom: var(--UI-Spacing-spacing-md); 39 | /* Centering is handled by parent container's align-items: center */ 40 | } 41 | 42 | .bespoke .racing-track-lane { 43 | position: absolute; 44 | left: 0; 45 | width: 100%; /* Default width, will be updated dynamically via JavaScript */ 46 | height: 75px; 47 | border-bottom: 2px dashed var(--Colors-Stroke-Light); 48 | } 49 | 50 | .bespoke .racing-track-lane[data-lane="0"] { 51 | top: 0; 52 | } 53 | 54 | .bespoke .racing-track-lane[data-lane="1"] { 55 | top: 75px; 56 | } 57 | 58 | .bespoke .racing-track-lane[data-lane="2"] { 59 | top: 150px; 60 | } 61 | 62 | .bespoke .racing-track-lane[data-lane="3"] { 63 | top: 225px; 64 | border-bottom: none; 65 | } 66 | 67 | .bespoke .racing-car { 68 | position: absolute; 69 | left: 20px; 70 | top: 50%; 71 | transform: translateY(-50%); 72 | transition: left 0.1s linear; 73 | z-index: 25; /* Above finish line (z-index: 20) */ 74 | } 75 | 76 | .bespoke .racing-car-player { 77 | z-index: 30; /* Player car above opponents */ 78 | } 79 | 80 | .bespoke .racing-text-overlay { 81 | position: absolute; 82 | top: 0; 83 | left: 70px; /* Start after car (car at 20px + car width 40px + 10px gap) */ 84 | right: 0; 85 | height: 75px; /* Same height as lane 0 */ 86 | pointer-events: none; 87 | z-index: 5; 88 | display: flex; 89 | align-items: center; 90 | padding: 0 var(--UI-Spacing-spacing-xl) 0 0; 91 | overflow-x: auto; 92 | overflow-y: hidden; 93 | scroll-behavior: smooth; 94 | scrollbar-width: none; /* Firefox */ 95 | -ms-overflow-style: none; /* IE and Edge */ 96 | } 97 | 98 | .bespoke .racing-text-overlay::-webkit-scrollbar { 99 | display: none; /* Chrome, Safari, Opera */ 100 | } 101 | 102 | .bespoke .racing-typing-text { 103 | width: 100%; 104 | font-family: var(--body-family); 105 | font-size: var(--Fonts-Body-Default-lg); 106 | line-height: 1.5; 107 | white-space: nowrap; 108 | overflow: visible; 109 | user-select: none; 110 | display: inline-block; 111 | min-width: 100%; 112 | } 113 | 114 | .bespoke .racing-typing-text .char-pending { 115 | color: var(--Colors-Text-Body-Light); 116 | } 117 | 118 | .bespoke .racing-typing-text .char-correct { 119 | color: var(--Colors-Text-Body-Strongest); 120 | } 121 | 122 | .bespoke .racing-typing-text .char-incorrect { 123 | color: var(--Colors-Alert-Error-Default); 124 | } 125 | 126 | .bespoke .racing-typing-text .char-incorrect.char-space { 127 | background-color: color-mix(in srgb, var(--Colors-Alert-Error-Default) 30%, transparent); 128 | border-radius: 2px; 129 | padding: 0 2px; 130 | } 131 | 132 | .bespoke .racing-typing-text .cursor-position { 133 | position: relative; 134 | } 135 | 136 | .bespoke .racing-typing-text .cursor-position::before { 137 | display: none; /* Hide cursor in racing game - car acts as cursor */ 138 | } 139 | 140 | .bespoke .racing-finish-line { 141 | position: absolute; 142 | right: 0; /* Position at right edge of track */ 143 | top: 0; 144 | bottom: 0; 145 | width: 4px; 146 | background: repeating-linear-gradient( 147 | to bottom, 148 | #000000 0px, 149 | #000000 10px, 150 | #FFFFFF 10px, 151 | #FFFFFF 20px 152 | ); 153 | z-index: 20; 154 | box-shadow: -2px 0 4px rgba(0, 0, 0, 0.2); 155 | } 156 | 157 | .bespoke .typing-container { 158 | position: relative; 159 | width: 100%; 160 | height: 100%; 161 | display: flex; 162 | flex-direction: column; 163 | align-items: center; 164 | justify-content: flex-start; 165 | padding: var(--UI-Spacing-spacing-xl); 166 | overflow: hidden; /* Prevent scrolling */ 167 | min-height: 0; /* Allow flexbox to shrink */ 168 | } 169 | 170 | /* Ensure racing track container is centered */ 171 | .bespoke .typing-container > .racing-track-container { 172 | align-self: center; 173 | } 174 | 175 | /* Keyboard and Stats Wrapper */ 176 | .bespoke .keyboard-stats-wrapper { 177 | position: relative; 178 | width: 100%; 179 | max-width: 900px; 180 | margin: var(--UI-Spacing-spacing-xl) auto 0; 181 | flex: 0 0 auto; /* Don't grow, don't shrink */ 182 | min-height: 0; /* Allow flexbox to shrink */ 183 | } 184 | 185 | /* Real-time Stats Container */ 186 | .bespoke .realtime-stats-container { 187 | display: none; 188 | position: absolute; 189 | left: 0; 190 | bottom: 0; 191 | flex-direction: column; 192 | gap: 2px; 193 | padding: var(--UI-Spacing-spacing-md); 194 | background: var(--Colors-Box-Background-Secondary); 195 | border: none; 196 | border-radius: var(--UI-Radius-radius-m); 197 | width: 140px; 198 | margin-right: var(--UI-Spacing-spacing-xl); 199 | z-index: 1; 200 | } 201 | 202 | .bespoke .realtime-stat-item { 203 | display: flex; 204 | flex-direction: row; 205 | align-items: center; 206 | justify-content: space-between; 207 | gap: var(--UI-Spacing-spacing-xs); 208 | padding: 0; 209 | min-width: 0; 210 | } 211 | 212 | .bespoke .realtime-stat-label { 213 | font-size: var(--Fonts-Body-Default-xs); 214 | color: var(--Colors-Text-Body-Medium); 215 | text-transform: uppercase; 216 | letter-spacing: 0.5px; 217 | font-weight: 500; 218 | white-space: nowrap; 219 | flex-shrink: 0; 220 | } 221 | 222 | .bespoke .realtime-stat-value { 223 | font-size: var(--Fonts-Headlines-sm); 224 | font-weight: 600; 225 | color: var(--Colors-Primary-Default); 226 | font-family: var(--heading-family); 227 | text-align: right; 228 | flex-shrink: 0; 229 | min-width: 60px; 230 | font-variant-numeric: tabular-nums; 231 | } 232 | 233 | .bespoke .typing-text-container { 234 | flex: 1 1 auto; 235 | position: relative; 236 | overflow: hidden; /* Remove scrolling - content must fit */ 237 | padding: var(--UI-Spacing-spacing-mxl); 238 | background: transparent; 239 | font-family: var(--body-family); 240 | font-size: var(--Fonts-Body-Default-xxxl); 241 | line-height: 140%; 242 | cursor: text; 243 | max-width: 900px; 244 | width: 100%; 245 | margin: var(--UI-Spacing-spacing-lg) auto 0; 246 | min-height: 0; /* Allow flexbox to shrink */ 247 | display: flex; 248 | flex-direction: column; 249 | } 250 | 251 | .bespoke .typing-text { 252 | white-space: pre-wrap; 253 | word-wrap: break-word; 254 | user-select: none; 255 | text-align: left; 256 | width: 100%; 257 | overflow: hidden; /* Prevent text from causing scroll */ 258 | flex: 1 1 auto; 259 | min-height: 0; 260 | } 261 | 262 | .bespoke .typing-text .char-pending { 263 | color: var(--Colors-Text-Body-Light); 264 | } 265 | 266 | .bespoke .typing-text .char-correct { 267 | color: var(--Colors-Text-Body-Strongest); 268 | } 269 | 270 | .bespoke .typing-text .char-incorrect { 271 | color: var(--Colors-Alert-Error-Default); 272 | } 273 | 274 | .bespoke .typing-text .char-incorrect.char-space { 275 | background-color: color-mix(in srgb, var(--Colors-Alert-Error-Default) 30%, transparent); 276 | border-radius: 2px; 277 | padding: 0 2px; 278 | } 279 | 280 | .bespoke .typing-text .cursor-position { 281 | position: relative; 282 | } 283 | 284 | .bespoke .typing-text .cursor-position::before { 285 | content: ''; 286 | position: absolute; 287 | left: -1px; 288 | top: 0; 289 | bottom: 0; 290 | width: 2px; 291 | background-color: var(--Colors-Primary-Default); 292 | animation: blink 1s step-end infinite; 293 | pointer-events: none; 294 | } 295 | 296 | @keyframes blink { 297 | 0%, 50% { 298 | opacity: 1; 299 | } 300 | 51%, 100% { 301 | opacity: 0; 302 | } 303 | } 304 | 305 | .bespoke .hidden-input { 306 | position: absolute; 307 | opacity: 0; 308 | pointer-events: none; 309 | width: 0; 310 | height: 0; 311 | border: none; 312 | padding: 0; 313 | margin: 0; 314 | } 315 | 316 | .bespoke .restart-button-container { 317 | position: fixed; 318 | bottom: var(--UI-Spacing-spacing-mxl); 319 | left: var(--UI-Spacing-spacing-mxl); 320 | z-index: 1000; 321 | } 322 | 323 | .bespoke .restart-button-container button { 324 | display: flex; 325 | align-items: center; 326 | justify-content: center; 327 | width: 40px; 328 | height: 40px; 329 | padding: 0; 330 | border-radius: var(--UI-Radius-radius-s); 331 | } 332 | 333 | .bespoke .restart-button-container button:active, 334 | .bespoke .restart-button-container button.active { 335 | box-shadow: none; 336 | border-bottom: none; 337 | color: var(--Colors-Buttons-Text-Primary-Hover); 338 | } 339 | 340 | .bespoke .restart-button-container button:focus, 341 | .bespoke .restart-button-container button.focus { 342 | box-shadow: none; 343 | border-bottom: none; 344 | outline: none; 345 | color: var(--Colors-Buttons-Text-Primary-Hover); 346 | } 347 | 348 | .bespoke .restart-button-container button svg { 349 | width: 20px; 350 | height: 20px; 351 | } 352 | 353 | .bespoke .completion-screen { 354 | display: none; 355 | position: fixed; 356 | top: 50%; 357 | left: 50%; 358 | transform: translate(-50%, -50%); 359 | flex-direction: column; 360 | align-items: center; 361 | justify-content: center; 362 | gap: var(--UI-Spacing-spacing-xl); 363 | padding: var(--UI-Spacing-spacing-xxxl); 364 | background: var(--Colors-Box-Background); 365 | border-radius: var(--UI-Radius-radius-m); 366 | border: 1px solid var(--Colors-Stroke-Default); 367 | text-align: center; 368 | z-index: 1000; 369 | min-width: 400px; 370 | max-width: 90vw; 371 | max-height: 90vh; 372 | overflow-y: auto; 373 | box-shadow: 0 3px 2px 0 var(--Colors-Shadow-Card); 374 | } 375 | 376 | .bespoke .completion-screen h2 { 377 | font-size: var(--Fonts-Headlines-md); 378 | margin: 0; 379 | color: var(--Colors-Text-Body-Strongest); 380 | font-family: var(--heading-family); 381 | font-weight: 500; 382 | } 383 | 384 | .bespoke .completion-screen p { 385 | font-size: var(--Fonts-Body-Default-lg); 386 | color: var(--Colors-Text-Body-Medium); 387 | margin: 0; 388 | } 389 | 390 | /* Stats Dashboard Styles */ 391 | .bespoke .stats-dashboard { 392 | display: none; 393 | position: fixed; 394 | top: 50%; 395 | left: 50%; 396 | transform: translate(-50%, -50%); 397 | flex-direction: column; 398 | align-items: center; 399 | justify-content: flex-start; 400 | gap: var(--UI-Spacing-spacing-xl); 401 | padding: var(--UI-Spacing-spacing-xl); 402 | background: var(--Colors-Backgrounds-Main-Default); 403 | text-align: center; 404 | z-index: 1000; 405 | min-width: 600px; 406 | max-width: 90vw; 407 | max-height: 90vh; 408 | overflow-y: auto; 409 | } 410 | 411 | .bespoke .stats-dashboard-header { 412 | width: 100%; 413 | margin-bottom: var(--UI-Spacing-spacing-lg); 414 | } 415 | 416 | .bespoke .stats-dashboard-header h2 { 417 | font-size: var(--Fonts-Headlines-xl); 418 | margin: 0 0 var(--UI-Spacing-spacing-xs) 0; 419 | color: var(--Colors-Text-Body-Strongest); 420 | font-family: var(--heading-family); 421 | font-weight: 500; 422 | } 423 | 424 | .bespoke .stats-subtitle { 425 | font-size: var(--Fonts-Body-Default-md); 426 | color: var(--Colors-Text-Body-Medium); 427 | margin: 0; 428 | } 429 | 430 | .bespoke .stats-grid { 431 | display: grid; 432 | grid-template-columns: repeat(5, 1fr); 433 | gap: var(--UI-Spacing-spacing-mxl); 434 | width: 100%; 435 | margin: var(--UI-Spacing-spacing-xl) 0; 436 | } 437 | 438 | .bespoke .stat-card { 439 | display: flex; 440 | flex-direction: column; 441 | align-items: center; 442 | justify-content: center; 443 | padding: var(--UI-Spacing-spacing-mxl); 444 | background: var(--Colors-Box-Background-Secondary); 445 | border: 1px solid var(--Colors-Stroke-Default); 446 | border-radius: var(--UI-Radius-radius-m); 447 | transition: transform 0.2s ease, box-shadow 0.2s ease; 448 | } 449 | 450 | .bespoke .stat-card:hover { 451 | transform: translateY(-2px); 452 | box-shadow: 0 4px 8px 0 var(--Colors-Shadow-Card); 453 | } 454 | 455 | .bespoke .stat-value-container { 456 | display: flex; 457 | align-items: center; 458 | justify-content: center; 459 | margin-bottom: var(--UI-Spacing-spacing-xs); 460 | } 461 | 462 | .bespoke .stat-value { 463 | font-size: var(--Fonts-Headlines-sm); 464 | font-weight: 600; 465 | color: var(--Colors-Primary-Default); 466 | font-family: var(--heading-family); 467 | } 468 | 469 | .bespoke .stat-label { 470 | font-size: var(--Fonts-Body-Default-sm); 471 | color: var(--Colors-Text-Body-Medium); 472 | text-transform: uppercase; 473 | letter-spacing: 0.5px; 474 | } 475 | 476 | .bespoke .stats-dashboard-footer { 477 | width: 100%; 478 | margin-top: var(--UI-Spacing-spacing-lg); 479 | } 480 | 481 | /* Mini Keyboard Styles */ 482 | .bespoke .keyboard-container { 483 | display: none; 484 | width: 100%; 485 | padding: var(--UI-Spacing-spacing-lg); 486 | background: transparent; 487 | overflow: hidden; /* Prevent keyboard from causing scroll */ 488 | } 489 | 490 | .bespoke .keyboard-container.visible { 491 | display: block; 492 | } 493 | 494 | .bespoke .keyboard-container .keyboard { 495 | margin: 0 auto; /* Center the keyboard */ 496 | } 497 | 498 | .bespoke .keyboard-container.visible { 499 | display: block; 500 | } 501 | 502 | .bespoke .keyboard { 503 | display: flex; 504 | flex-direction: column; 505 | gap: var(--UI-Spacing-spacing-xs); 506 | font-family: var(--body-family); 507 | font-size: 12px; 508 | user-select: none; 509 | } 510 | 511 | .bespoke .keyboard-row { 512 | display: flex; 513 | justify-content: center; 514 | gap: var(--UI-Spacing-spacing-xs); 515 | } 516 | 517 | .bespoke .keyboard-key { 518 | display: flex; 519 | align-items: center; 520 | justify-content: center; 521 | min-width: 28px; 522 | height: 32px; 523 | padding: 0 var(--UI-Spacing-spacing-sm); 524 | background: var(--Colors-Box-Background-Secondary); 525 | border: 1px solid var(--Colors-Stroke-Default); 526 | border-radius: var(--UI-Radius-radius-s); 527 | color: var(--Colors-Text-Body-Strongest); 528 | font-weight: 500; 529 | transition: all 0.15s ease; 530 | box-shadow: 0 1px 2px 0 var(--Colors-Shadow-Card); 531 | } 532 | 533 | .bespoke .keyboard-key.space { 534 | min-width: 200px; 535 | } 536 | 537 | .bespoke .keyboard-key.enter { 538 | min-width: 60px; 539 | } 540 | 541 | .bespoke .keyboard-key.shift { 542 | min-width: 70px; 543 | } 544 | 545 | .bespoke .keyboard-key.backspace { 546 | min-width: 70px; 547 | } 548 | 549 | .bespoke .keyboard-key.tab { 550 | min-width: 50px; 551 | } 552 | 553 | .bespoke .keyboard-key.caps { 554 | min-width: 60px; 555 | } 556 | 557 | .bespoke .keyboard-key.active { 558 | background: color-mix(in srgb, var(--Colors-Primary-Default) 30%, transparent); 559 | border-color: var(--Colors-Primary-Default); 560 | transform: scale(0.95); 561 | } 562 | 563 | .bespoke .keyboard-key.active-error { 564 | background: color-mix(in srgb, var(--Colors-Alert-Error-Default) 30%, transparent); 565 | border-color: var(--Colors-Alert-Error-Default); 566 | transform: scale(0.95); 567 | } 568 | 569 | .bespoke .keyboard-key.unavailable { 570 | background: var(--Colors-Box-Background); 571 | border-color: var(--Colors-Stroke-Light); 572 | color: var(--Colors-Text-Body-Light); 573 | opacity: 0.5; 574 | cursor: not-allowed; 575 | } 576 | 577 | /* Racing Track Responsive Styles */ 578 | @media (max-width: 1024px) { 579 | .bespoke .racing-track { 580 | height: 250px; 581 | } 582 | 583 | .bespoke .racing-track-lane { 584 | height: 62.5px; 585 | } 586 | 587 | .bespoke .racing-track-lane[data-lane="1"] { 588 | top: 62.5px; 589 | } 590 | 591 | .bespoke .racing-track-lane[data-lane="2"] { 592 | top: 125px; 593 | } 594 | 595 | .bespoke .racing-track-lane[data-lane="3"] { 596 | top: 187.5px; 597 | } 598 | 599 | .bespoke .racing-text-overlay { 600 | height: 62.5px; /* Match lane 0 height */ 601 | } 602 | 603 | .bespoke .racing-typing-text { 604 | font-size: var(--Fonts-Body-Default-md); 605 | } 606 | } 607 | 608 | @media (max-width: 768px) { 609 | .bespoke .racing-track { 610 | height: 200px; 611 | } 612 | 613 | .bespoke .racing-track-lane { 614 | height: 50px; 615 | } 616 | 617 | .bespoke .racing-track-lane[data-lane="1"] { 618 | top: 50px; 619 | } 620 | 621 | .bespoke .racing-track-lane[data-lane="2"] { 622 | top: 100px; 623 | } 624 | 625 | .bespoke .racing-track-lane[data-lane="3"] { 626 | top: 150px; 627 | } 628 | 629 | .bespoke .racing-text-overlay { 630 | height: 50px; /* Match lane 0 height */ 631 | } 632 | 633 | .bespoke .racing-car svg { 634 | width: 32px; 635 | height: 32px; 636 | } 637 | 638 | .bespoke .racing-typing-text { 639 | font-size: var(--Fonts-Body-Default-sm); 640 | } 641 | } 642 | 643 | @media (max-width: 480px) { 644 | .bespoke .racing-track { 645 | height: 180px; 646 | } 647 | 648 | .bespoke .racing-track-lane { 649 | height: 45px; 650 | } 651 | 652 | .bespoke .racing-track-lane[data-lane="1"] { 653 | top: 45px; 654 | } 655 | 656 | .bespoke .racing-track-lane[data-lane="2"] { 657 | top: 90px; 658 | } 659 | 660 | .bespoke .racing-track-lane[data-lane="3"] { 661 | top: 135px; 662 | } 663 | 664 | .bespoke .racing-text-overlay { 665 | height: 45px; /* Match lane 0 height */ 666 | } 667 | 668 | .bespoke .racing-car svg { 669 | width: 28px; 670 | height: 28px; 671 | } 672 | 673 | .bespoke .racing-typing-text { 674 | font-size: var(--Fonts-Body-Default-xs); 675 | } 676 | } 677 | 678 | /* Responsive Design */ 679 | @media (max-width: 1024px) { 680 | .bespoke .keyboard-stats-wrapper { 681 | margin: var(--UI-Spacing-spacing-lg) auto 0; 682 | } 683 | 684 | .bespoke .realtime-stats-container { 685 | gap: 2px; 686 | padding: var(--UI-Spacing-spacing-sm); 687 | min-width: 130px; 688 | } 689 | 690 | .bespoke .realtime-stat-item { 691 | padding: 0; 692 | gap: var(--UI-Spacing-spacing-sm); 693 | } 694 | 695 | .bespoke .realtime-stat-label { 696 | font-size: var(--Fonts-Body-Default-xxs); 697 | } 698 | 699 | .bespoke .realtime-stat-value { 700 | font-size: var(--Fonts-Headlines-xs); 701 | } 702 | 703 | .bespoke .typing-text-container { 704 | font-size: var(--Fonts-Body-Default-xxl); 705 | padding: var(--UI-Spacing-spacing-xl); 706 | margin: var(--UI-Spacing-spacing-md) auto 0; 707 | } 708 | 709 | .bespoke .keyboard-container { 710 | padding: var(--UI-Spacing-spacing-md); 711 | } 712 | 713 | .bespoke .keyboard { 714 | font-size: 11px; 715 | } 716 | 717 | .bespoke .keyboard-key { 718 | min-width: 24px; 719 | height: 28px; 720 | padding: 0 var(--UI-Spacing-spacing-xs); 721 | } 722 | 723 | .bespoke .keyboard-key.space { 724 | min-width: 150px; 725 | } 726 | 727 | .bespoke .keyboard-key.enter { 728 | min-width: 50px; 729 | } 730 | 731 | .bespoke .keyboard-key.shift { 732 | min-width: 60px; 733 | } 734 | 735 | .bespoke .keyboard-key.backspace { 736 | min-width: 60px; 737 | } 738 | 739 | .bespoke .keyboard-key.tab { 740 | min-width: 40px; 741 | } 742 | 743 | .bespoke .keyboard-key.caps { 744 | min-width: 50px; 745 | } 746 | } 747 | 748 | @media (max-width: 768px) { 749 | .bespoke .keyboard-stats-wrapper { 750 | margin: var(--UI-Spacing-spacing-md) auto 0; 751 | } 752 | 753 | .bespoke .realtime-stats-container { 754 | position: static; 755 | width: 100%; 756 | max-width: 100%; 757 | min-width: auto; 758 | gap: 2px; 759 | padding: var(--UI-Spacing-spacing-sm); 760 | margin-bottom: var(--UI-Spacing-spacing-md); 761 | } 762 | 763 | .bespoke .realtime-stat-item { 764 | padding: 0; 765 | gap: var(--UI-Spacing-spacing-sm); 766 | } 767 | 768 | .bespoke .keyboard-container { 769 | width: 100%; 770 | } 771 | 772 | .bespoke .typing-container { 773 | padding: var(--UI-Spacing-spacing-md); 774 | } 775 | 776 | .bespoke .typing-text-container { 777 | font-size: var(--Fonts-Body-Default-xl); 778 | padding: var(--UI-Spacing-spacing-lg); 779 | margin: var(--UI-Spacing-spacing-sm) auto 0; 780 | } 781 | 782 | .bespoke .completion-screen { 783 | min-width: 90vw; 784 | max-width: 90vw; 785 | padding: var(--UI-Spacing-spacing-xl); 786 | gap: var(--UI-Spacing-spacing-lg); 787 | } 788 | 789 | .bespoke .completion-screen h2 { 790 | font-size: var(--Fonts-Headlines-sm); 791 | } 792 | 793 | .bespoke .completion-screen p { 794 | font-size: var(--Fonts-Body-Default-md); 795 | } 796 | 797 | .bespoke .stats-dashboard { 798 | min-width: 90vw; 799 | max-width: 90vw; 800 | padding: var(--UI-Spacing-spacing-xl); 801 | gap: var(--UI-Spacing-spacing-lg); 802 | } 803 | 804 | .bespoke .stats-dashboard-header h2 { 805 | font-size: var(--Fonts-Headlines-lg); 806 | } 807 | 808 | .bespoke .stats-subtitle { 809 | font-size: var(--Fonts-Body-Default-sm); 810 | } 811 | 812 | .bespoke .stats-grid { 813 | grid-template-columns: repeat(5, 1fr); 814 | gap: var(--UI-Spacing-spacing-m); 815 | } 816 | 817 | .bespoke .stat-card { 818 | padding: var(--UI-Spacing-spacing-m); 819 | } 820 | 821 | .bespoke .stat-value { 822 | font-size: var(--Fonts-Headlines-xs); 823 | } 824 | 825 | .bespoke .stat-label { 826 | font-size: var(--Fonts-Body-Default-xs); 827 | } 828 | 829 | .bespoke .keyboard-container { 830 | padding: var(--UI-Spacing-spacing-sm); 831 | margin: var(--UI-Spacing-spacing-md) auto 0; 832 | } 833 | 834 | .bespoke .keyboard { 835 | font-size: 10px; 836 | gap: 2px; 837 | } 838 | 839 | .bespoke .keyboard-row { 840 | gap: 2px; 841 | } 842 | 843 | .bespoke .keyboard-key { 844 | min-width: 20px; 845 | height: 24px; 846 | padding: 0 4px; 847 | font-size: 10px; 848 | } 849 | 850 | .bespoke .keyboard-key.space { 851 | min-width: 120px; 852 | } 853 | 854 | .bespoke .keyboard-key.enter { 855 | min-width: 40px; 856 | } 857 | 858 | .bespoke .keyboard-key.shift { 859 | min-width: 50px; 860 | } 861 | 862 | .bespoke .keyboard-key.backspace { 863 | min-width: 50px; 864 | } 865 | 866 | .bespoke .keyboard-key.tab { 867 | min-width: 35px; 868 | } 869 | 870 | .bespoke .keyboard-key.caps { 871 | min-width: 40px; 872 | } 873 | 874 | .bespoke .restart-button-container { 875 | bottom: var(--UI-Spacing-spacing-md); 876 | left: var(--UI-Spacing-spacing-md); 877 | } 878 | 879 | .bespoke .restart-button-container button { 880 | width: 36px; 881 | height: 36px; 882 | } 883 | 884 | .bespoke .restart-button-container button svg { 885 | width: 18px; 886 | height: 18px; 887 | } 888 | } 889 | 890 | /* Meteorite Rain Game Styles */ 891 | .bespoke .meteorite-rain-container { 892 | flex: 1 1 auto; 893 | position: relative; 894 | width: 100%; 895 | height: 100%; 896 | display: flex; 897 | flex-direction: column; 898 | min-height: 0; 899 | background: #000232; 900 | overflow: hidden; 901 | } 902 | 903 | .bespoke .meteorite-rain-game { 904 | position: relative; 905 | width: 100%; 906 | height: 100%; 907 | display: flex; 908 | flex-direction: column; 909 | } 910 | 911 | .bespoke .meteorite-rain-ui { 912 | position: absolute; 913 | top: var(--UI-Spacing-spacing-xl); 914 | left: var(--UI-Spacing-spacing-xl); 915 | right: var(--UI-Spacing-spacing-xl); 916 | z-index: 100; 917 | display: flex; 918 | flex-direction: row; 919 | justify-content: space-between; 920 | align-items: center; 921 | gap: var(--UI-Spacing-spacing-xl); 922 | pointer-events: none; 923 | } 924 | 925 | .bespoke .meteorite-score { 926 | font-size: var(--Fonts-Headlines-xl); 927 | font-weight: 600; 928 | color: #FFFFFF; 929 | font-family: var(--heading-family); 930 | line-height: 1; 931 | } 932 | 933 | .bespoke .meteorite-lives { 934 | display: flex; 935 | flex-direction: row; 936 | gap: var(--UI-Spacing-spacing-sm); 937 | align-items: center; 938 | } 939 | 940 | .bespoke .meteorite-heart { 941 | width: 20px; 942 | height: 20px; 943 | background-image: url('./assets/heart.svg'); 944 | background-size: contain; 945 | background-repeat: no-repeat; 946 | background-position: center; 947 | display: inline-block; 948 | } 949 | 950 | .bespoke .meteorite-start-message { 951 | position: absolute; 952 | top: 50%; 953 | left: 50%; 954 | transform: translate(-50%, -50%); 955 | z-index: 200; 956 | font-size: var(--Fonts-Headlines-lg); 957 | font-weight: 600; 958 | color: #FFFFFF; 959 | font-family: var(--heading-family); 960 | text-align: center; 961 | pointer-events: none; 962 | display: flex; 963 | align-items: center; 964 | justify-content: center; 965 | } 966 | 967 | .bespoke .meteorite-rain-play-area { 968 | flex: 1 1 auto; 969 | position: relative; 970 | width: 100%; 971 | min-height: 0; 972 | overflow: hidden; 973 | } 974 | 975 | .bespoke .meteorite { 976 | position: absolute; 977 | display: flex; 978 | flex-direction: column; 979 | align-items: center; 980 | gap: var(--UI-Spacing-spacing-xs); 981 | z-index: 10; 982 | } 983 | 984 | .bespoke .meteorite-circle { 985 | width: 40px; 986 | height: 40px; 987 | background-image: url('./assets/meteor.svg'); 988 | background-size: contain; 989 | background-repeat: no-repeat; 990 | background-position: center; 991 | } 992 | 993 | .bespoke .meteorite-word { 994 | font-size: var(--Fonts-Body-Default-sm); 995 | font-weight: 600; 996 | color: #FFFFFF; 997 | white-space: nowrap; 998 | pointer-events: none; 999 | text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); 1000 | } 1001 | 1002 | .bespoke .meteorite-ground { 1003 | position: absolute; 1004 | bottom: 0; 1005 | left: 0; 1006 | right: 0; 1007 | height: 20px; 1008 | background: #C4C4C4; 1009 | z-index: 5; 1010 | box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.2); 1011 | } 1012 | 1013 | .bespoke .meteorite-typing-input-container { 1014 | position: absolute; 1015 | bottom: 30px; 1016 | left: 50%; 1017 | transform: translateX(-50%); 1018 | z-index: 50; 1019 | display: flex; 1020 | flex-direction: column; 1021 | align-items: center; 1022 | min-width: 200px; 1023 | max-width: 300px; 1024 | width: auto; 1025 | } 1026 | 1027 | .bespoke .meteorite-typing-input-container .input { 1028 | width: 100%; 1029 | } 1030 | 1031 | @media (max-width: 480px) { 1032 | .bespoke .keyboard-stats-wrapper { 1033 | margin: var(--UI-Spacing-spacing-sm) auto 0; 1034 | } 1035 | 1036 | .bespoke .realtime-stats-container { 1037 | position: static; 1038 | width: 100%; 1039 | max-width: 100%; 1040 | min-width: auto; 1041 | gap: 2px; 1042 | padding: var(--UI-Spacing-spacing-xs); 1043 | margin-bottom: var(--UI-Spacing-spacing-sm); 1044 | } 1045 | 1046 | .bespoke .realtime-stat-item { 1047 | padding: 0; 1048 | gap: var(--UI-Spacing-spacing-xs); 1049 | } 1050 | 1051 | .bespoke .typing-container { 1052 | padding: var(--UI-Spacing-spacing-sm); 1053 | } 1054 | 1055 | .bespoke .typing-text-container { 1056 | font-size: var(--Fonts-Body-Default-lg); 1057 | padding: var(--UI-Spacing-spacing-md); 1058 | margin: var(--UI-Spacing-spacing-xs) auto 0; 1059 | } 1060 | 1061 | .bespoke .completion-screen { 1062 | padding: var(--UI-Spacing-spacing-lg); 1063 | gap: var(--UI-Spacing-spacing-md); 1064 | } 1065 | 1066 | .bespoke .stats-dashboard { 1067 | padding: var(--UI-Spacing-spacing-lg); 1068 | gap: var(--UI-Spacing-spacing-md); 1069 | } 1070 | 1071 | .bespoke .stats-grid { 1072 | grid-template-columns: repeat(5, 1fr); 1073 | gap: var(--UI-Spacing-spacing-s); 1074 | } 1075 | 1076 | .bespoke .stat-card { 1077 | padding: var(--UI-Spacing-spacing-s); 1078 | } 1079 | 1080 | .bespoke .stat-value { 1081 | font-size: 0.875rem; 1082 | } 1083 | 1084 | .bespoke .keyboard-container { 1085 | padding: var(--UI-Spacing-spacing-xs); 1086 | margin: var(--UI-Spacing-spacing-sm) auto 0; 1087 | } 1088 | 1089 | .bespoke .keyboard { 1090 | font-size: 8px; 1091 | gap: 1px; 1092 | } 1093 | 1094 | .bespoke .keyboard-row { 1095 | gap: 1px; 1096 | } 1097 | 1098 | .bespoke .keyboard-key { 1099 | min-width: 16px; 1100 | height: 20px; 1101 | padding: 0 2px; 1102 | font-size: 8px; 1103 | } 1104 | 1105 | .bespoke .keyboard-key.space { 1106 | min-width: 100px; 1107 | } 1108 | 1109 | .bespoke .keyboard-key.enter { 1110 | min-width: 35px; 1111 | } 1112 | 1113 | .bespoke .keyboard-key.shift { 1114 | min-width: 40px; 1115 | } 1116 | 1117 | .bespoke .keyboard-key.backspace { 1118 | min-width: 40px; 1119 | } 1120 | 1121 | .bespoke .keyboard-key.tab { 1122 | min-width: 30px; 1123 | } 1124 | 1125 | .bespoke .keyboard-key.caps { 1126 | min-width: 35px; 1127 | } 1128 | } 1129 | -------------------------------------------------------------------------------- /client/typing-simulator.js: -------------------------------------------------------------------------------- 1 | // typing-simulator.js 2 | (function() { 3 | let originalText = ''; 4 | let typedText = ''; 5 | let textContainer = null; 6 | let hiddenInput = null; 7 | let completionScreen = null; 8 | let statsDashboard = null; 9 | let restartButton = null; 10 | let startOverButton = null; 11 | let statsStartOverButton = null; 12 | let keyboardContainer = null; 13 | let realtimeStatsContainer = null; 14 | let config = { keyboard: true, availableKeys: [], showStats: false, realTimeStats: [], gameType: 'classic' }; 15 | 16 | // Normalized set of available keys (for fast lookup) 17 | let availableKeysSet = new Set(); 18 | 19 | // Character states: 'pending', 'correct', 'incorrect' 20 | const charStates = []; 21 | 22 | // Statistics tracking 23 | let startTime = null; 24 | let totalErrors = 0; 25 | let totalInputs = 0; 26 | 27 | // Keyboard state 28 | let keyboardEnabled = false; 29 | let activeKeyElement = null; 30 | let activeKeyTimeout = null; 31 | 32 | // Real-time stats update interval 33 | let realtimeStatsInterval = null; 34 | 35 | // Game manager - handles different game types 36 | let currentGame = null; 37 | let gameUpdateInterval = null; 38 | let gameAnimationFrame = null; 39 | 40 | function setStatus(msg) { 41 | // Status element removed - function kept for compatibility but does nothing 42 | } 43 | 44 | // Load configuration 45 | async function loadConfig() { 46 | try { 47 | const response = await fetch('./config.json'); 48 | if (!response.ok) { 49 | console.warn('Config file not found, using defaults'); 50 | return; 51 | } 52 | config = await response.json(); 53 | 54 | // Normalize available keys to lowercase for fast lookup 55 | // Empty array means all keys are available 56 | if (config.availableKeys && Array.isArray(config.availableKeys) && config.availableKeys.length > 0) { 57 | availableKeysSet = new Set(config.availableKeys.map(key => key.toLowerCase())); 58 | } else { 59 | availableKeysSet = new Set(); // Empty set means all keys available 60 | } 61 | 62 | // Set default game type if not specified 63 | if (!config.gameType) { 64 | config.gameType = 'classic'; 65 | } 66 | } catch (error) { 67 | console.warn('Error loading config:', error); 68 | } 69 | } 70 | 71 | // ==================== GAME MANAGER ==================== 72 | 73 | // Racing Game Implementation 74 | class RacingGame { 75 | constructor() { 76 | this.trackContainer = document.getElementById('racing-track-container'); 77 | this.typingTextElement = document.getElementById('racing-typing-text'); 78 | this.playerCar = document.getElementById('car-player'); 79 | this.opponentCars = [ 80 | document.getElementById('car-opponent-1'), 81 | document.getElementById('car-opponent-2'), 82 | document.getElementById('car-opponent-3') 83 | ]; 84 | this.finishLine = document.getElementById('racing-finish-line'); 85 | this.trackElement = this.trackContainer ? this.trackContainer.querySelector('.racing-track') : null; 86 | 87 | this.opponentPositions = [0, 0, 0]; 88 | this.opponentSpeeds = config.racing?.opponentSpeeds || [0.3, 0.4, 0.5]; 89 | // Convert speeds from pixels per frame (at 60fps) to pixels per second 90 | // Original speeds: 0.3, 0.4, 0.5 px/frame at 60fps = 18, 24, 30 px/s 91 | this.opponentSpeedsPxPerSec = this.opponentSpeeds.map(speed => speed * 60); 92 | // Current speeds with randomness (initialized to base speeds) 93 | this.currentOpponentSpeeds = [...this.opponentSpeedsPxPerSec]; 94 | this.speedUpdateTimer = 0; // Timer for speed updates (in milliseconds) 95 | this.speedUpdateInterval = 1500 + Math.random() * 1000; // Update speed every 1.5-2.5 seconds 96 | this.lastFrameTime = null; // For delta time calculation 97 | this.trackWidth = 0; 98 | this.finishLineTextPosition = 0; // Position in text coordinates 99 | this.isFinished = false; 100 | this.playerWon = null; // null = not finished, true = player won, false = player lost 101 | } 102 | 103 | initialize() { 104 | if (!this.trackContainer || !this.trackElement) return; 105 | 106 | // Show racing track, hide classic view 107 | this.trackContainer.style.display = 'block'; 108 | const classicContainer = document.getElementById('classic-typing-container'); 109 | if (classicContainer) { 110 | classicContainer.style.display = 'none'; 111 | } 112 | 113 | // Calculate track dimensions 114 | this.updateTrackDimensions(); 115 | 116 | // Reset positions 117 | this.reset(); 118 | } 119 | 120 | updateTrackDimensions() { 121 | if (!this.trackElement) return; 122 | this.trackWidth = this.trackElement.offsetWidth; 123 | // Finish line position will be calculated dynamically based on text end 124 | } 125 | 126 | reset() { 127 | this.opponentPositions = [0, 0, 0]; 128 | this.isFinished = false; 129 | this.finishLineTextPosition = 0; 130 | this.playerWon = null; // Reset win/loss status 131 | // Reset speeds to base speeds (in px/s) 132 | this.currentOpponentSpeeds = [...this.opponentSpeedsPxPerSec]; 133 | this.speedUpdateTimer = 0; 134 | this.speedUpdateInterval = 1500 + Math.random() * 1000; // Reset update interval 135 | this.lastFrameTime = null; // Reset frame time 136 | 137 | if (this.playerCar) { 138 | this.playerCar.style.left = '20px'; 139 | } 140 | 141 | this.opponentCars.forEach((car, index) => { 142 | if (car) { 143 | car.style.left = '20px'; 144 | } 145 | this.opponentPositions[index] = 0; 146 | }); 147 | 148 | // Update finish line position after reset 149 | setTimeout(() => { 150 | this.updateFinishLinePosition(); 151 | }, 0); 152 | } 153 | 154 | updateOpponentSpeeds() { 155 | // Update speeds with small random variations 156 | // Variations are ±20% of base speed to keep it realistic 157 | this.opponentSpeedsPxPerSec.forEach((baseSpeed, index) => { 158 | const variation = 0.2; // ±20% variation 159 | const randomFactor = 1 + (Math.random() * 2 - 1) * variation; // Random between 0.8 and 1.2 160 | this.currentOpponentSpeeds[index] = baseSpeed * randomFactor; 161 | }); 162 | 163 | // Reset timer and set new random interval 164 | this.speedUpdateTimer = 0; 165 | this.speedUpdateInterval = 1500 + Math.random() * 1000; // 1.5-2.5 seconds 166 | } 167 | 168 | updatePlayerPosition() { 169 | if (!this.playerCar || this.isFinished || !this.typingTextElement) return; 170 | 171 | // Get the cursor element position 172 | const cursorElement = this.typingTextElement.querySelector('.cursor-position'); 173 | if (!cursorElement) { 174 | // If no cursor, position at start of text 175 | const firstChar = this.typingTextElement.querySelector('span'); 176 | if (firstChar) { 177 | const carWidth = 40; 178 | const position = 70 + firstChar.offsetLeft - carWidth; // Car front at first character 179 | this.playerCar.style.left = `${Math.max(20, position)}px`; 180 | } 181 | return; 182 | } 183 | 184 | // Get the absolute position of the cursor within the text element 185 | const cursorLeft = cursorElement.offsetLeft; 186 | 187 | // Position car so its front (right edge) is at cursor position 188 | const carWidth = 40; // Car SVG width 189 | const position = 70 + cursorLeft - carWidth; // 70px is where text starts, car front at cursor 190 | 191 | this.playerCar.style.left = `${Math.max(20, position)}px`; 192 | 193 | // Update finish line position to end of text 194 | this.updateFinishLinePosition(); 195 | 196 | // Check if player crossed finish line 197 | if (cursorLeft >= this.finishLineTextPosition && !this.isFinished) { 198 | this.isFinished = true; 199 | this.playerWon = true; // Player finished first 200 | // Completion will be handled by the main renderText function 201 | } 202 | } 203 | 204 | updateFinishLinePosition() { 205 | if (!this.typingTextElement || !this.finishLine) return; 206 | 207 | // Find the last character span 208 | const allChars = this.typingTextElement.querySelectorAll('span'); 209 | if (allChars.length === 0) { 210 | // No text yet - set lanes and track to minimum width 211 | const minWidth = 70; // At least where text starts 212 | const lanes = this.trackElement ? this.trackElement.querySelectorAll('.racing-track-lane') : []; 213 | lanes.forEach(lane => { 214 | lane.style.width = `${minWidth}px`; 215 | }); 216 | if (this.trackElement) { 217 | this.trackElement.style.width = `${minWidth}px`; 218 | } 219 | // Finish line is positioned with right: 0 in CSS, so it automatically aligns with track edge 220 | return; 221 | } 222 | 223 | const lastChar = allChars[allChars.length - 1]; 224 | const finishLineTextPosition = lastChar.offsetLeft + lastChar.offsetWidth; 225 | this.finishLineTextPosition = finishLineTextPosition; 226 | 227 | // Position finish line at the end of text with a buffer 228 | const buffer = 20; // Buffer space between text end and finish line 229 | const finishLinePosition = 70 + finishLineTextPosition + buffer; // 70px is where text starts 230 | 231 | // Update track lanes to end at finish line 232 | const lanes = this.trackElement ? this.trackElement.querySelectorAll('.racing-track-lane') : []; 233 | lanes.forEach(lane => { 234 | lane.style.width = `${finishLinePosition}px`; 235 | }); 236 | 237 | // Update racing-track container width to match finish line 238 | if (this.trackElement) { 239 | this.trackElement.style.width = `${finishLinePosition}px`; 240 | } 241 | 242 | // Finish line is positioned with right: 0 in CSS, so it automatically aligns with track edge 243 | } 244 | 245 | updateOpponents(currentTime) { 246 | if (this.isFinished || !startTime) return; 247 | 248 | // Calculate delta time (time since last frame) in seconds 249 | let deltaTime = 0; 250 | if (this.lastFrameTime !== null) { 251 | deltaTime = (currentTime - this.lastFrameTime) / 1000; // Convert to seconds 252 | // Clamp delta time to prevent large jumps (e.g., when tab regains focus) 253 | deltaTime = Math.min(deltaTime, 0.1); // Max 100ms delta (10fps minimum) 254 | } 255 | this.lastFrameTime = currentTime; 256 | 257 | // Skip update if this is the first frame (no delta time yet) 258 | if (deltaTime === 0) return; 259 | 260 | // Update finish line position first (in case text changed) 261 | this.updateFinishLinePosition(); 262 | 263 | // Update speeds periodically with randomness 264 | this.speedUpdateTimer += deltaTime * 1000; // Convert to milliseconds 265 | if (this.speedUpdateTimer >= this.speedUpdateInterval) { 266 | this.updateOpponentSpeeds(); 267 | } 268 | 269 | // Get finish line X position - it's at the right edge of the track 270 | // Since finish line uses right: 0, we need to get track width 271 | const trackWidth = this.trackElement ? this.trackElement.offsetWidth : 0; 272 | const finishLineX = trackWidth; // Finish line is at the right edge 273 | 274 | this.opponentCars.forEach((car, index) => { 275 | if (!car) return; 276 | 277 | // Use current speed (with randomness) in pixels per second 278 | // Multiply by deltaTime to get frame-rate independent movement 279 | const speedPxPerSec = this.currentOpponentSpeeds[index] || this.opponentSpeedsPxPerSec[index] || 18; 280 | const movementThisFrame = speedPxPerSec * deltaTime; // pixels this frame 281 | this.opponentPositions[index] += movementThisFrame; 282 | 283 | // Calculate car position 284 | // Car's left edge is at: 20px (start) + opponentPositions[index] 285 | // Car's right edge (front) is at: 20px + opponentPositions[index] + 40px (car width) 286 | const carWidth = 40; 287 | const carLeftPosition = 20 + this.opponentPositions[index]; 288 | const carFrontPosition = carLeftPosition + carWidth; 289 | 290 | // Opponents move based on their position, finish line is at end of text 291 | const maxPosition = finishLineX > 0 ? finishLineX - 20 : this.trackWidth - 20; 292 | const position = Math.min(carLeftPosition, maxPosition); 293 | car.style.left = `${position}px`; 294 | 295 | // Check if opponent's front touches finish line (with small buffer for visibility) 296 | const buffer = 10; // Small buffer so it's obvious to user 297 | if (finishLineX > 0 && carFrontPosition >= finishLineX - buffer && !this.isFinished) { 298 | this.isFinished = true; 299 | this.playerWon = false; // Opponent finished first 300 | // Trigger completion screen when opponent wins 301 | console.log('Opponent finished first! Showing completion screen.'); 302 | showCompletionScreen(); 303 | } 304 | }); 305 | } 306 | 307 | renderText(textHtml) { 308 | if (this.typingTextElement) { 309 | this.typingTextElement.innerHTML = textHtml; 310 | 311 | // Wait for DOM to update, then update positions 312 | setTimeout(() => { 313 | this.updatePlayerPosition(); 314 | this.updateFinishLinePosition(); 315 | }, 0); 316 | } 317 | } 318 | 319 | destroy() { 320 | // Cleanup if needed 321 | this.isFinished = false; 322 | } 323 | } 324 | 325 | // Classic Game Implementation (original behavior) 326 | class ClassicGame { 327 | constructor() { 328 | this.textContainer = document.getElementById('typing-text'); 329 | } 330 | 331 | initialize() { 332 | // Show classic view, hide racing track 333 | const classicContainer = document.getElementById('classic-typing-container'); 334 | const racingContainer = document.getElementById('racing-track-container'); 335 | 336 | if (classicContainer) { 337 | classicContainer.style.display = 'flex'; 338 | } 339 | if (racingContainer) { 340 | racingContainer.style.display = 'none'; 341 | } 342 | } 343 | 344 | reset() { 345 | // Nothing to reset for classic game 346 | } 347 | 348 | updatePlayerPosition(progress) { 349 | // No visual position update for classic game 350 | } 351 | 352 | updateOpponents() { 353 | // No opponents in classic game 354 | } 355 | 356 | renderText(textHtml) { 357 | if (this.textContainer) { 358 | this.textContainer.innerHTML = textHtml; 359 | } 360 | } 361 | 362 | destroy() { 363 | // Cleanup if needed 364 | } 365 | } 366 | 367 | // Meteorite Rain Game Implementation 368 | class MeteoriteRainGame { 369 | constructor() { 370 | this.container = document.getElementById('meteorite-rain-container'); 371 | this.playArea = document.getElementById('meteorite-play-area'); 372 | this.scoreElement = document.getElementById('meteorite-score'); 373 | this.livesElement = document.getElementById('meteorite-lives'); 374 | this.typingInput = document.getElementById('meteorite-typing-input'); 375 | this.ground = document.getElementById('meteorite-ground'); 376 | this.startMessage = document.getElementById('meteorite-start-message'); 377 | this.heartElements = null; // Will be populated when lives element is available 378 | 379 | this.meteorites = []; 380 | this.words = []; 381 | this.score = 0; 382 | this.lives = 3; 383 | this.isFinished = false; 384 | this.hasStarted = false; // Track if game has started 385 | this.currentTypedWord = ''; 386 | this.spawnTimer = 0; 387 | this.lastFrameTime = null; 388 | this.gameStartTime = null; 389 | this.pointsPerChar = config.meteoriteRain?.pointsPerChar || 100; 390 | this.spawnIntervalId = null; 391 | this.animationFrame = null; 392 | this.startKeyListener = null; // Store reference to start key listener 393 | 394 | // Difficulty settings 395 | const difficultyConfig = config.meteoriteRain?.difficulty || {}; 396 | this.baseSpawnInterval = difficultyConfig.baseSpawnInterval || config.meteoriteRain?.spawnInterval || 2000; 397 | this.minSpawnInterval = difficultyConfig.minSpawnInterval || 500; 398 | this.baseSpeed = difficultyConfig.baseSpeed || config.meteoriteRain?.meteoriteSpeed || 50; 399 | this.maxSpeed = difficultyConfig.maxSpeed || 150; 400 | this.difficultyIncreaseRate = difficultyConfig.difficultyIncreaseRate || 0.1; 401 | } 402 | 403 | initialize() { 404 | if (!this.container || !this.playArea) return; 405 | 406 | // Show meteorite rain container, hide others 407 | this.container.style.display = 'flex'; 408 | const classicContainer = document.getElementById('classic-typing-container'); 409 | const racingContainer = document.getElementById('racing-track-container'); 410 | if (classicContainer) { 411 | classicContainer.style.display = 'none'; 412 | } 413 | if (racingContainer) { 414 | racingContainer.style.display = 'none'; 415 | } 416 | 417 | // Reset game state 418 | this.reset(); 419 | } 420 | 421 | extractWords() { 422 | // Split text into words (split by whitespace and filter empty strings) 423 | this.words = originalText 424 | .split(/\s+/) 425 | .filter(word => word.length > 0) 426 | .map(word => word.toLowerCase().trim()); 427 | 428 | // Remove duplicates while preserving order 429 | this.words = [...new Set(this.words)]; 430 | 431 | console.log('Extracted words:', this.words); 432 | } 433 | 434 | reset() { 435 | // Extract words if not already extracted 436 | if (this.words.length === 0 && originalText.length > 0) { 437 | this.extractWords(); 438 | } 439 | 440 | // Clear all meteorites 441 | this.meteorites.forEach(meteorite => { 442 | if (meteorite.element && meteorite.element.parentNode) { 443 | meteorite.element.parentNode.removeChild(meteorite.element); 444 | } 445 | }); 446 | this.meteorites = []; 447 | 448 | // Reset game state 449 | this.score = 0; 450 | this.lives = 3; 451 | this.isFinished = false; 452 | this.hasStarted = false; 453 | this.currentTypedWord = ''; 454 | this.spawnTimer = 0; 455 | this.lastFrameTime = null; 456 | this.gameStartTime = null; 457 | this.heartElements = null; // Reset heart elements cache 458 | 459 | // Remove old start key listener if exists 460 | if (this.startKeyListener) { 461 | document.removeEventListener('keydown', this.startKeyListener); 462 | this.startKeyListener = null; 463 | } 464 | 465 | // Show start message 466 | if (this.startMessage) { 467 | this.startMessage.style.display = 'flex'; 468 | } 469 | 470 | // Update UI 471 | this.updateScore(); 472 | this.updateLives(); 473 | this.updateTypingDisplay(); 474 | 475 | // Clear intervals and animation frames 476 | if (this.spawnIntervalId) { 477 | clearTimeout(this.spawnIntervalId); 478 | this.spawnIntervalId = null; 479 | } 480 | if (this.animationFrame !== null) { 481 | cancelAnimationFrame(this.animationFrame); 482 | this.animationFrame = null; 483 | } 484 | } 485 | 486 | getCurrentDifficulty() { 487 | if (!this.gameStartTime) return 0; 488 | const elapsedSeconds = (Date.now() - this.gameStartTime) / 1000; 489 | // Difficulty increases linearly over time 490 | return Math.min(elapsedSeconds * this.difficultyIncreaseRate, 1.0); 491 | } 492 | 493 | getRandomSpawnInterval() { 494 | const difficulty = this.getCurrentDifficulty(); 495 | // As difficulty increases, spawn interval decreases 496 | // At difficulty 0: baseSpawnInterval 497 | // At difficulty 1: minSpawnInterval 498 | const minInterval = this.minSpawnInterval; 499 | const maxInterval = this.baseSpawnInterval; 500 | const currentMaxInterval = maxInterval - (maxInterval - minInterval) * difficulty; 501 | 502 | // Random interval between 70% and 100% of current max interval 503 | const randomFactor = 0.7 + Math.random() * 0.3; 504 | return Math.max(minInterval, currentMaxInterval * randomFactor); 505 | } 506 | 507 | getCurrentSpeed() { 508 | const difficulty = this.getCurrentDifficulty(); 509 | // As difficulty increases, speed increases 510 | // At difficulty 0: baseSpeed 511 | // At difficulty 1: maxSpeed 512 | return this.baseSpeed + (this.maxSpeed - this.baseSpeed) * difficulty; 513 | } 514 | 515 | spawnMeteorite() { 516 | if (this.isFinished || !this.hasStarted || this.words.length === 0) return; 517 | 518 | // Pick a random word 519 | const word = this.words[Math.floor(Math.random() * this.words.length)]; 520 | 521 | // Get current speed for this meteorite 522 | const currentSpeed = this.getCurrentSpeed(); 523 | 524 | // Create meteorite element 525 | const meteorite = document.createElement('div'); 526 | meteorite.className = 'meteorite'; 527 | 528 | const circle = document.createElement('div'); 529 | circle.className = 'meteorite-circle'; 530 | 531 | const wordElement = document.createElement('div'); 532 | wordElement.className = 'meteorite-word'; 533 | wordElement.textContent = word; 534 | 535 | meteorite.appendChild(circle); 536 | meteorite.appendChild(wordElement); 537 | this.playArea.appendChild(meteorite); 538 | 539 | // Random horizontal position (with padding from edges to avoid score and hearts) 540 | const padding = 100; 541 | const maxX = this.playArea.offsetWidth - padding; 542 | const x = padding + Math.random() * (maxX - padding); 543 | 544 | meteorite.style.left = `${x}px`; 545 | meteorite.style.top = '0px'; 546 | 547 | // Store meteorite data with its speed 548 | const meteoriteData = { 549 | element: meteorite, 550 | word: word, 551 | y: 0, 552 | x: x, 553 | speed: currentSpeed // Each meteorite has its own speed based on when it was spawned 554 | }; 555 | 556 | this.meteorites.push(meteoriteData); 557 | } 558 | 559 | updateMeteorites(currentTime) { 560 | if (this.isFinished || !this.hasStarted) return; 561 | 562 | // Calculate delta time 563 | let deltaTime = 0; 564 | if (this.lastFrameTime !== null) { 565 | deltaTime = (currentTime - this.lastFrameTime) / 1000; 566 | deltaTime = Math.min(deltaTime, 0.1); // Clamp to prevent large jumps 567 | } 568 | this.lastFrameTime = currentTime; 569 | 570 | if (deltaTime === 0) return; 571 | 572 | const playAreaHeight = this.playArea.offsetHeight; 573 | const groundHeight = this.ground ? this.ground.offsetHeight : 20; 574 | 575 | // Update each meteorite position 576 | this.meteorites.forEach((meteorite, index) => { 577 | // Move meteorite down using its individual speed 578 | const speedPxPerSec = meteorite.speed || this.baseSpeed; 579 | const movementThisFrame = speedPxPerSec * deltaTime; 580 | meteorite.y += movementThisFrame; 581 | meteorite.element.style.top = `${meteorite.y}px`; 582 | 583 | // Check if meteorite hit the ground 584 | const meteoriteBottom = meteorite.y + meteorite.element.offsetHeight; 585 | if (meteoriteBottom >= playAreaHeight - groundHeight) { 586 | // Meteorite hit the ground - lose a life 587 | this.loseLife(); 588 | this.destroyMeteorite(index); 589 | } 590 | }); 591 | 592 | // Check if game should end 593 | if (this.lives <= 0 && !this.isFinished) { 594 | this.endGame(); 595 | } 596 | } 597 | 598 | destroyMeteorite(index) { 599 | const meteorite = this.meteorites[index]; 600 | if (meteorite && meteorite.element && meteorite.element.parentNode) { 601 | meteorite.element.parentNode.removeChild(meteorite.element); 602 | } 603 | this.meteorites.splice(index, 1); 604 | } 605 | 606 | checkWordMatch(typedWord) { 607 | if (!typedWord || typedWord.length === 0) return false; 608 | 609 | const typedLower = typedWord.toLowerCase().trim(); 610 | 611 | // Find matching meteorite 612 | for (let i = 0; i < this.meteorites.length; i++) { 613 | const meteorite = this.meteorites[i]; 614 | if (meteorite.word.toLowerCase() === typedLower) { 615 | // Match! Destroy meteorite and award points 616 | const points = meteorite.word.length * this.pointsPerChar; 617 | this.score += points; 618 | this.updateScore(); 619 | this.destroyMeteorite(i); 620 | return true; 621 | } 622 | } 623 | return false; 624 | } 625 | 626 | loseLife() { 627 | if (this.lives > 0) { 628 | this.lives--; 629 | this.updateLives(); 630 | } 631 | } 632 | 633 | endGame() { 634 | this.isFinished = true; 635 | 636 | // Stop spawning 637 | if (this.spawnIntervalId) { 638 | clearTimeout(this.spawnIntervalId); 639 | this.spawnIntervalId = null; 640 | } 641 | 642 | // Stop animation 643 | if (this.animationFrame !== null) { 644 | cancelAnimationFrame(this.animationFrame); 645 | this.animationFrame = null; 646 | } 647 | 648 | // Show completion screen with score 649 | showCompletionScreen(); 650 | } 651 | 652 | updateScore() { 653 | if (this.scoreElement) { 654 | this.scoreElement.textContent = this.score; 655 | } 656 | } 657 | 658 | updateLives() { 659 | if (!this.livesElement) return; 660 | 661 | // Get or cache heart elements 662 | if (!this.heartElements) { 663 | this.heartElements = this.livesElement.querySelectorAll('.meteorite-heart'); 664 | } 665 | 666 | // Show/hide hearts based on current lives 667 | if (this.heartElements) { 668 | this.heartElements.forEach((heart, index) => { 669 | if (index < this.lives) { 670 | heart.style.display = 'inline-block'; 671 | } else { 672 | heart.style.display = 'none'; 673 | } 674 | }); 675 | } 676 | } 677 | 678 | updateTypingDisplay() { 679 | // Input element is managed by handleInput, no need to update display separately 680 | } 681 | 682 | setTypedWord(word) { 683 | this.currentTypedWord = word; 684 | // Input value is managed by the input element itself 685 | } 686 | 687 | beginGame() { 688 | // Mark game as started 689 | this.hasStarted = true; 690 | 691 | // Hide start message 692 | if (this.startMessage) { 693 | this.startMessage.style.display = 'none'; 694 | } 695 | 696 | // Remove start key listener 697 | if (this.startKeyListener) { 698 | document.removeEventListener('keydown', this.startKeyListener); 699 | this.startKeyListener = null; 700 | } 701 | 702 | // Set game start time for difficulty calculation 703 | this.gameStartTime = Date.now(); 704 | 705 | // Start spawning meteorites with dynamic intervals 706 | this.spawnMeteorite(); // Spawn first one immediately 707 | 708 | // Schedule next spawn with random interval based on difficulty 709 | const scheduleNextSpawn = () => { 710 | if (this.isFinished || !this.hasStarted) return; 711 | 712 | const nextInterval = this.getRandomSpawnInterval(); 713 | this.spawnIntervalId = setTimeout(() => { 714 | if (!this.isFinished && this.hasStarted) { 715 | this.spawnMeteorite(); 716 | scheduleNextSpawn(); // Schedule next spawn 717 | } 718 | }, nextInterval); 719 | }; 720 | 721 | scheduleNextSpawn(); 722 | 723 | // Start animation loop 724 | const animate = (currentTime) => { 725 | if (this.isFinished || !this.hasStarted) return; 726 | this.updateMeteorites(currentTime); 727 | this.animationFrame = requestAnimationFrame(animate); 728 | }; 729 | this.animationFrame = requestAnimationFrame(animate); 730 | 731 | // Focus the input element 732 | if (this.typingInput) { 733 | setTimeout(() => { 734 | if (this.typingInput) { 735 | this.typingInput.focus(); 736 | } 737 | }, 100); 738 | } 739 | } 740 | 741 | startGame() { 742 | // Set up key listener for starting the game 743 | this.setupStartListener(); 744 | } 745 | 746 | setupStartListener() { 747 | // Listen for Enter or Space to start the game 748 | this.startKeyListener = (e) => { 749 | if (this.hasStarted || this.isFinished) return; 750 | 751 | if (e.key === 'Enter' || e.key === 'Return' || e.key === ' ' || e.key === 'Space') { 752 | e.preventDefault(); 753 | this.beginGame(); 754 | } 755 | }; 756 | 757 | document.addEventListener('keydown', this.startKeyListener); 758 | } 759 | 760 | renderText(textHtml) { 761 | // Not used in meteorite rain game 762 | } 763 | 764 | destroy() { 765 | // Cleanup 766 | if (this.spawnIntervalId) { 767 | clearTimeout(this.spawnIntervalId); 768 | this.spawnIntervalId = null; 769 | } 770 | if (this.animationFrame !== null) { 771 | cancelAnimationFrame(this.animationFrame); 772 | this.animationFrame = null; 773 | } 774 | if (this.startKeyListener) { 775 | document.removeEventListener('keydown', this.startKeyListener); 776 | this.startKeyListener = null; 777 | } 778 | this.reset(); 779 | } 780 | 781 | getScore() { 782 | return this.score; 783 | } 784 | } 785 | 786 | // Game Manager 787 | function initializeGame() { 788 | // Clean up previous game 789 | if (currentGame) { 790 | currentGame.destroy(); 791 | if (gameUpdateInterval) { 792 | clearInterval(gameUpdateInterval); 793 | gameUpdateInterval = null; 794 | } 795 | if (gameAnimationFrame !== null) { 796 | cancelAnimationFrame(gameAnimationFrame); 797 | gameAnimationFrame = null; 798 | } 799 | } 800 | 801 | // Initialize based on game type 802 | const gameType = config.gameType || 'classic'; 803 | 804 | if (gameType === 'racing') { 805 | currentGame = new RacingGame(); 806 | } else if (gameType === 'meteoriteRain') { 807 | currentGame = new MeteoriteRainGame(); 808 | } else { 809 | currentGame = new ClassicGame(); 810 | } 811 | 812 | currentGame.initialize(); 813 | 814 | // Start game update loop for racing using requestAnimationFrame 815 | if (gameType === 'racing' && currentGame instanceof RacingGame) { 816 | function animate(currentTime) { 817 | if (currentGame && currentGame.updateOpponents) { 818 | currentGame.updateOpponents(currentTime); 819 | } 820 | // Continue animation loop 821 | gameAnimationFrame = requestAnimationFrame(animate); 822 | } 823 | // Start the animation loop 824 | gameAnimationFrame = requestAnimationFrame(animate); 825 | } 826 | 827 | // Start meteorite rain game 828 | if (gameType === 'meteoriteRain' && currentGame instanceof MeteoriteRainGame) { 829 | // Start the game after a short delay to ensure DOM is ready 830 | setTimeout(() => { 831 | if (currentGame && currentGame.startGame) { 832 | currentGame.startGame(); 833 | } 834 | }, 100); 835 | } 836 | 837 | // Re-render text if it's already loaded (not for meteorite rain) 838 | if (originalText.length > 0 && gameType !== 'meteoriteRain') { 839 | renderText(); 840 | } 841 | } 842 | 843 | // Keyboard layout definition 844 | const keyboardLayout = [ 845 | ['`', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '=', 'backspace'], 846 | ['tab', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '[', ']', '\\'], 847 | ['caps', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', "'", 'enter'], 848 | ['shift', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '/', 'shift'], 849 | ['space'] 850 | ]; 851 | 852 | // Map special keys to display names 853 | const keyDisplayNames = { 854 | 'backspace': '⌫', 855 | 'tab': 'Tab', 856 | 'caps': 'Caps', 857 | 'enter': 'Enter', 858 | 'shift': 'Shift', 859 | 'space': 'Space' 860 | }; 861 | 862 | // Check if a key is available for typing 863 | // Accepts both character values (from input) and KeyboardEvent.key values 864 | function isKeyAvailable(key) { 865 | // Space, comma, dot, backspace, and enter are ALWAYS available 866 | const keyLower = key.toLowerCase(); 867 | if (key === ' ' || key === '\u00A0' || key === ',' || key === '.' || 868 | key === 'Backspace' || key === '\b' || key === 'Enter' || key === 'Return' || 869 | key === '\n' || key === '\r' || 870 | keyLower === 'space' || keyLower === 'comma' || keyLower === 'dot' || 871 | keyLower === 'backspace' || keyLower === 'enter') { 872 | return true; 873 | } 874 | 875 | // If no available keys are configured, all keys are available 876 | if (availableKeysSet.size === 0) { 877 | return true; 878 | } 879 | 880 | // Handle KeyboardEvent.key values (e.g., "Tab") 881 | if (key === 'Tab' || keyLower === 'tab') { 882 | return availableKeysSet.has('tab'); 883 | } 884 | 885 | // Handle character values (from input events) 886 | if (key === '\t') { 887 | return availableKeysSet.has('tab'); 888 | } 889 | 890 | // For regular keys, normalize to lowercase and check 891 | // Handle both single characters and KeyboardEvent.key values 892 | const normalizedKey = key.length === 1 ? key.toLowerCase() : keyLower; 893 | return availableKeysSet.has(normalizedKey); 894 | } 895 | 896 | // Get key element by character 897 | function getKeyElement(char) { 898 | if (!keyboardContainer) return null; 899 | 900 | // Normalize character 901 | const normalizedChar = char.toLowerCase(); 902 | 903 | // Handle special keys 904 | if (char === ' ') { 905 | return keyboardContainer.querySelector('[data-key="space"]'); 906 | } 907 | if (char === '\n' || char === '\r') { 908 | return keyboardContainer.querySelector('[data-key="enter"]'); 909 | } 910 | if (char === '\t') { 911 | return keyboardContainer.querySelector('[data-key="tab"]'); 912 | } 913 | 914 | // Find regular key 915 | return keyboardContainer.querySelector(`[data-key="${normalizedChar}"]`); 916 | } 917 | 918 | // Highlight a key on the keyboard 919 | function highlightKey(char, isError = false) { 920 | // Don't highlight unavailable keys 921 | if (!isKeyAvailable(char)) { 922 | return; 923 | } 924 | 925 | // Clear previous highlight 926 | if (activeKeyElement) { 927 | activeKeyElement.classList.remove('active', 'active-error'); 928 | } 929 | 930 | // Clear timeout if exists 931 | if (activeKeyTimeout) { 932 | clearTimeout(activeKeyTimeout); 933 | } 934 | 935 | const keyElement = getKeyElement(char); 936 | if (keyElement) { 937 | activeKeyElement = keyElement; 938 | if (isError) { 939 | keyElement.classList.add('active-error'); 940 | } else { 941 | keyElement.classList.add('active'); 942 | } 943 | 944 | // Remove highlight after animation 945 | activeKeyTimeout = setTimeout(() => { 946 | if (keyElement) { 947 | keyElement.classList.remove('active', 'active-error'); 948 | } 949 | activeKeyElement = null; 950 | }, 200); 951 | } 952 | } 953 | 954 | // Render the keyboard 955 | function renderKeyboard() { 956 | if (!keyboardContainer) return; 957 | 958 | const keyboard = document.createElement('div'); 959 | keyboard.className = 'keyboard'; 960 | 961 | keyboardLayout.forEach(row => { 962 | const rowElement = document.createElement('div'); 963 | rowElement.className = 'keyboard-row'; 964 | 965 | row.forEach(key => { 966 | const keyElement = document.createElement('div'); 967 | const normalizedKey = key.toLowerCase(); 968 | keyElement.className = 'keyboard-key'; 969 | keyElement.setAttribute('data-key', normalizedKey); 970 | 971 | // Check if this key is available (use isKeyAvailable to ensure space, comma, dot are always available) 972 | const isAvailable = isKeyAvailable(key); 973 | if (!isAvailable) { 974 | keyElement.classList.add('unavailable'); 975 | } 976 | 977 | // Add special class for certain keys 978 | if (key === 'space' || key === 'enter' || key === 'shift' || 979 | key === 'backspace' || key === 'tab' || key === 'caps') { 980 | keyElement.classList.add(key); 981 | } 982 | 983 | // Set display text 984 | if (keyDisplayNames[key]) { 985 | keyElement.textContent = keyDisplayNames[key]; 986 | } else { 987 | keyElement.textContent = key.toUpperCase(); 988 | } 989 | 990 | rowElement.appendChild(keyElement); 991 | }); 992 | 993 | keyboard.appendChild(rowElement); 994 | }); 995 | 996 | keyboardContainer.innerHTML = ''; 997 | keyboardContainer.appendChild(keyboard); 998 | } 999 | 1000 | // Initialize keyboard 1001 | function initializeKeyboard() { 1002 | keyboardContainer = document.getElementById('keyboard-container'); 1003 | if (!keyboardContainer) return; 1004 | 1005 | keyboardEnabled = config.keyboard === true; 1006 | 1007 | if (keyboardEnabled) { 1008 | renderKeyboard(); 1009 | keyboardContainer.classList.add('visible'); 1010 | } else { 1011 | keyboardContainer.classList.remove('visible'); 1012 | } 1013 | } 1014 | 1015 | async function loadText() { 1016 | try { 1017 | setStatus('Loading...'); 1018 | const response = await fetch('./text-to-input.txt'); 1019 | if (!response.ok) { 1020 | throw new Error('Failed to load text file'); 1021 | } 1022 | originalText = await response.text(); 1023 | // Replace all newlines with spaces (for single-line display in racing mode) 1024 | originalText = originalText.replace(/\n/g, ' '); 1025 | // Trim trailing whitespace 1026 | originalText = originalText.trimEnd(); 1027 | 1028 | // Initialize character states 1029 | charStates.length = 0; 1030 | for (let i = 0; i < originalText.length; i++) { 1031 | charStates.push('pending'); 1032 | } 1033 | 1034 | renderText(); 1035 | setStatus('Ready'); 1036 | } catch (error) { 1037 | console.error('Error loading text:', error); 1038 | setStatus('Failed to load data'); 1039 | if (textContainer) { 1040 | textContainer.innerHTML = '

Error: Could not load text file.

'; 1041 | } 1042 | } 1043 | } 1044 | 1045 | function renderText() { 1046 | // Calculate correct characters count 1047 | let correctCharsCount = 0; 1048 | for (let i = 0; i < charStates.length; i++) { 1049 | if (charStates[i] === 'correct') { 1050 | correctCharsCount++; 1051 | } 1052 | } 1053 | 1054 | // Check if completed based on correct characters requirement 1055 | const mistakesAllowed = config.racing?.mistakesAllowed ?? 0; 1056 | const requiredCorrectChars = originalText.length - mistakesAllowed; 1057 | 1058 | if (correctCharsCount >= requiredCorrectChars && originalText.length > 0) { 1059 | console.log('Completion detected! Showing completion screen.'); 1060 | console.log('Correct chars:', correctCharsCount, 'Required:', requiredCorrectChars); 1061 | 1062 | // For racing game, mark player as winner if not already finished 1063 | if (config.gameType === 'racing' && currentGame && !currentGame.isFinished) { 1064 | currentGame.isFinished = true; 1065 | currentGame.playerWon = true; 1066 | } 1067 | 1068 | showCompletionScreen(); 1069 | return; 1070 | } 1071 | 1072 | // Hide completion screen if visible 1073 | if (completionScreen) { 1074 | completionScreen.style.display = 'none'; 1075 | } 1076 | 1077 | // Calculate progress for racing game based on correct characters and required amount 1078 | // (reuse mistakesAllowed and requiredCorrectChars from above) 1079 | const progress = requiredCorrectChars > 0 ? Math.min(correctCharsCount / requiredCorrectChars, 1.0) : 0; 1080 | 1081 | // Render text based on game type 1082 | const isRacing = config.gameType === 'racing'; 1083 | let html = ''; 1084 | const currentPosition = typedText.length; 1085 | 1086 | for (let i = 0; i < originalText.length; i++) { 1087 | const char = originalText[i]; 1088 | const state = charStates[i]; 1089 | let className = 'char-'; 1090 | 1091 | if (i < typedText.length) { 1092 | // Character has been typed 1093 | if (state === 'incorrect') { 1094 | className += 'incorrect'; 1095 | } else { 1096 | className += 'correct'; 1097 | } 1098 | } else { 1099 | // Character not yet typed 1100 | className += 'pending'; 1101 | } 1102 | 1103 | // Handle special characters that need escaping 1104 | let displayChar = char; 1105 | const isSpace = char === ' '; 1106 | if (isSpace) { 1107 | displayChar = '\u00A0'; // Non-breaking space 1108 | className += ' char-space'; // Add class to identify spaces 1109 | } else if (char === '\n') { 1110 | // For racing, convert newlines to spaces (single line display) 1111 | displayChar = isRacing ? '\u00A0' : '
'; 1112 | if (isRacing) { 1113 | className += ' char-space'; // Add class for newlines converted to spaces 1114 | } 1115 | } else { 1116 | displayChar = escapeHtml(char); 1117 | } 1118 | 1119 | // Add cursor class to the character at the typing position 1120 | if (i === currentPosition) { 1121 | className += ' cursor-position'; 1122 | } 1123 | 1124 | html += `${displayChar}`; 1125 | } 1126 | 1127 | // If all characters are typed, add a cursor position marker at the end 1128 | if (currentPosition === originalText.length) { 1129 | html += '\u00A0'; 1130 | } 1131 | 1132 | // Use game's renderText method 1133 | if (currentGame && currentGame.renderText) { 1134 | currentGame.renderText(html); 1135 | } else if (textContainer) { 1136 | // Fallback to classic rendering 1137 | textContainer.innerHTML = html; 1138 | } 1139 | 1140 | // Update player position in racing game (car follows cursor) 1141 | if (isRacing && currentGame && currentGame.updatePlayerPosition) { 1142 | currentGame.updatePlayerPosition(); 1143 | } 1144 | } 1145 | 1146 | function escapeHtml(text) { 1147 | const div = document.createElement('div'); 1148 | div.textContent = text; 1149 | return div.innerHTML; 1150 | } 1151 | 1152 | function handleInput(e) { 1153 | let input = e.target.value; 1154 | 1155 | // Special handling for meteorite rain game (word-based typing) 1156 | if (config.gameType === 'meteoriteRain' && currentGame instanceof MeteoriteRainGame) { 1157 | // Don't allow typing if game hasn't started 1158 | if (!currentGame.hasStarted) { 1159 | e.target.value = ''; 1160 | return; 1161 | } 1162 | 1163 | // Filter out unavailable keys if availableKeys is configured 1164 | if (availableKeysSet.size > 0) { 1165 | let filteredInput = ''; 1166 | for (let i = 0; i < input.length; i++) { 1167 | const char = input[i]; 1168 | if (isKeyAvailable(char)) { 1169 | filteredInput += char; 1170 | } 1171 | } 1172 | input = filteredInput; 1173 | e.target.value = input; 1174 | } 1175 | 1176 | // Update game with current typed word (no spaces allowed in word input) 1177 | const currentWord = input.trim(); 1178 | if (currentGame.setTypedWord) { 1179 | currentGame.setTypedWord(currentWord); 1180 | } 1181 | 1182 | // Highlight keys for meteorite rain 1183 | if (keyboardEnabled && currentWord.length > 0) { 1184 | const lastChar = currentWord[currentWord.length - 1]; 1185 | highlightKey(lastChar, false); 1186 | } 1187 | 1188 | return; // Don't process further for meteorite rain (Enter key handled in handleKeyDown) 1189 | } 1190 | 1191 | // Original character-by-character handling for other game types 1192 | // Filter out unavailable keys if availableKeys is configured 1193 | if (availableKeysSet.size > 0) { 1194 | let filteredInput = ''; 1195 | for (let i = 0; i < input.length; i++) { 1196 | const char = input[i]; 1197 | if (isKeyAvailable(char)) { 1198 | filteredInput += char; 1199 | } 1200 | } 1201 | input = filteredInput; 1202 | e.target.value = input; 1203 | } 1204 | 1205 | // Start timer on first keypress 1206 | if (startTime === null && input.length > 0) { 1207 | startTime = Date.now(); 1208 | } 1209 | 1210 | // Prevent typing beyond the original text length 1211 | if (input.length > originalText.length) { 1212 | input = input.slice(0, originalText.length); 1213 | e.target.value = input; 1214 | } 1215 | 1216 | const inputLength = input.length; 1217 | const typedLength = typedText.length; 1218 | 1219 | // Handle typing forward 1220 | if (inputLength > typedLength) { 1221 | const newChars = input.slice(typedLength); 1222 | let validInput = typedText; // Start with current valid text 1223 | 1224 | for (let i = 0; i < newChars.length; i++) { 1225 | const charIndex = typedLength + i; 1226 | if (charIndex >= originalText.length) { 1227 | break; 1228 | } 1229 | 1230 | const expectedChar = originalText[charIndex]; 1231 | const typedChar = newChars[i]; 1232 | 1233 | totalInputs++; // Track total inputs 1234 | 1235 | const isError = typedChar !== expectedChar; 1236 | if (isError) { 1237 | // Don't add incorrect character to input, but count as error 1238 | totalErrors++; // Track total errors 1239 | 1240 | // Highlight keyboard key to show error 1241 | if (keyboardEnabled) { 1242 | highlightKey(typedChar, true); 1243 | } 1244 | 1245 | // Reset input to valid text (reject the incorrect character) 1246 | e.target.value = validInput; 1247 | input = validInput; 1248 | break; // Stop processing further characters 1249 | } else { 1250 | // Character is correct - add it to valid input 1251 | validInput += typedChar; 1252 | charStates[charIndex] = 'correct'; 1253 | 1254 | // Highlight keyboard key 1255 | if (keyboardEnabled) { 1256 | highlightKey(typedChar, false); 1257 | } 1258 | } 1259 | } 1260 | typedText = validInput; 1261 | } 1262 | // Handle backspace/delete 1263 | else if (inputLength < typedLength) { 1264 | typedText = input; 1265 | // Reset states for characters that are no longer typed 1266 | for (let i = inputLength; i < originalText.length; i++) { 1267 | if (i < charStates.length) { 1268 | charStates[i] = 'pending'; 1269 | } 1270 | } 1271 | 1272 | // Highlight backspace key (only if available) 1273 | if (keyboardEnabled && isKeyAvailable('backspace')) { 1274 | highlightKey('backspace', false); 1275 | } 1276 | } 1277 | 1278 | renderText(); 1279 | updateRealtimeStats(); 1280 | } 1281 | 1282 | function handleKeyDown(e) { 1283 | // Special handling for meteorite rain game - Space key submits word or starts game 1284 | if (config.gameType === 'meteoriteRain' && currentGame instanceof MeteoriteRainGame) { 1285 | // Space key submits word or starts game 1286 | if (e.key === ' ' || e.key === 'Space') { 1287 | e.preventDefault(); // Prevent default space behavior 1288 | 1289 | // If game hasn't started, start it 1290 | if (!currentGame.hasStarted) { 1291 | currentGame.beginGame(); 1292 | return; 1293 | } 1294 | 1295 | const inputElement = document.getElementById('meteorite-typing-input'); 1296 | if (inputElement) { 1297 | const wordToCheck = inputElement.value.trim(); 1298 | if (wordToCheck.length > 0) { 1299 | // Check if word matches a meteorite 1300 | if (currentGame.checkWordMatch && currentGame.checkWordMatch(wordToCheck)) { 1301 | // Word matched! Clear input 1302 | inputElement.value = ''; 1303 | if (currentGame.setTypedWord) { 1304 | currentGame.setTypedWord(''); 1305 | } 1306 | } else { 1307 | // Word didn't match, clear input anyway (player can try again) 1308 | inputElement.value = ''; 1309 | if (currentGame.setTypedWord) { 1310 | currentGame.setTypedWord(''); 1311 | } 1312 | } 1313 | } 1314 | } 1315 | return; 1316 | } 1317 | 1318 | // Enter key can also start the game (for convenience) 1319 | if ((e.key === 'Enter' || e.key === 'Return') && !currentGame.hasStarted) { 1320 | e.preventDefault(); 1321 | currentGame.beginGame(); 1322 | return; 1323 | } 1324 | } 1325 | 1326 | // Handle Enter key for other game types - check availability but let textarea handle insertion 1327 | if (e.key === 'Enter' || e.key === 'Return') { 1328 | if (!isKeyAvailable('\n')) { 1329 | e.preventDefault(); // Prevent if not available 1330 | return; 1331 | } 1332 | 1333 | // Check if we can still type (not beyond original text length) 1334 | if (hiddenInput.value.length >= originalText.length) { 1335 | e.preventDefault(); // Can't type beyond original text 1336 | return; 1337 | } 1338 | 1339 | // Let the browser handle the newline insertion naturally 1340 | // Highlight keyboard key if enabled 1341 | if (keyboardEnabled) { 1342 | // Use setTimeout to highlight after the newline is inserted 1343 | setTimeout(() => { 1344 | highlightKey('\n', false); 1345 | }, 0); 1346 | } 1347 | 1348 | // The input event will fire naturally, no need to manually trigger 1349 | return; 1350 | } 1351 | 1352 | // Handle Tab key - manually insert tab character 1353 | if (e.key === 'Tab') { 1354 | e.preventDefault(); // Prevent tab from moving focus 1355 | 1356 | if (!isKeyAvailable('\t')) { 1357 | return; // Key not available, don't insert 1358 | } 1359 | 1360 | // Check if we can still type (not beyond original text length) 1361 | if (hiddenInput.value.length >= originalText.length) { 1362 | return; // Can't type beyond original text 1363 | } 1364 | 1365 | // Get current cursor position 1366 | const cursorPos = hiddenInput.selectionStart || hiddenInput.value.length; 1367 | 1368 | // Insert tab at cursor position 1369 | const currentValue = hiddenInput.value; 1370 | const newValue = currentValue.slice(0, cursorPos) + '\t' + currentValue.slice(cursorPos); 1371 | 1372 | // Update input value 1373 | hiddenInput.value = newValue; 1374 | 1375 | // Move cursor after the inserted tab 1376 | setTimeout(() => { 1377 | hiddenInput.setSelectionRange(cursorPos + 1, cursorPos + 1); 1378 | }, 0); 1379 | 1380 | // Highlight keyboard key if enabled 1381 | if (keyboardEnabled) { 1382 | highlightKey('\t', false); 1383 | } 1384 | 1385 | // Manually trigger input event to process the tab 1386 | const inputEvent = new Event('input', { bubbles: true }); 1387 | hiddenInput.dispatchEvent(inputEvent); 1388 | 1389 | return; 1390 | } 1391 | 1392 | // Prevent unavailable keys from being typed (skip for meteorite rain - it has its own input) 1393 | if (config.gameType !== 'meteoriteRain' && availableKeysSet.size > 0 && !isKeyAvailable(e.key)) { 1394 | e.preventDefault(); 1395 | return; 1396 | } 1397 | 1398 | // Prevent default behavior for backspace when at start (only for hiddenInput, not meteorite input) 1399 | if (e.key === 'Backspace' && e.target === hiddenInput && hiddenInput.value.length === 0) { 1400 | e.preventDefault(); 1401 | } 1402 | } 1403 | 1404 | function restart() { 1405 | typedText = ''; 1406 | for (let i = 0; i < charStates.length; i++) { 1407 | charStates[i] = 'pending'; 1408 | } 1409 | if (hiddenInput) { 1410 | hiddenInput.value = ''; 1411 | } 1412 | 1413 | // Reset statistics 1414 | startTime = null; 1415 | totalErrors = 0; 1416 | totalInputs = 0; 1417 | 1418 | // Clear real-time stats interval 1419 | if (realtimeStatsInterval) { 1420 | clearInterval(realtimeStatsInterval); 1421 | realtimeStatsInterval = null; 1422 | } 1423 | 1424 | // Update real-time stats display 1425 | updateRealtimeStats(); 1426 | 1427 | // Clear keyboard highlights 1428 | if (activeKeyElement) { 1429 | activeKeyElement.classList.remove('active', 'active-error'); 1430 | activeKeyElement = null; 1431 | } 1432 | if (activeKeyTimeout) { 1433 | clearTimeout(activeKeyTimeout); 1434 | activeKeyTimeout = null; 1435 | } 1436 | 1437 | // Reset game 1438 | if (currentGame && currentGame.reset) { 1439 | currentGame.reset(); 1440 | } 1441 | 1442 | // Show appropriate container and hide completion screen and stats dashboard 1443 | const isRacing = config.gameType === 'racing'; 1444 | const isMeteoriteRain = config.gameType === 'meteoriteRain'; 1445 | 1446 | if (isRacing) { 1447 | const racingContainer = document.getElementById('racing-track-container'); 1448 | if (racingContainer) { 1449 | racingContainer.style.display = 'block'; 1450 | } 1451 | const classicContainer = document.getElementById('classic-typing-container'); 1452 | if (classicContainer) { 1453 | classicContainer.style.display = 'none'; 1454 | } 1455 | const meteoriteContainer = document.getElementById('meteorite-rain-container'); 1456 | if (meteoriteContainer) { 1457 | meteoriteContainer.style.display = 'none'; 1458 | } 1459 | } else if (isMeteoriteRain) { 1460 | const meteoriteContainer = document.getElementById('meteorite-rain-container'); 1461 | if (meteoriteContainer) { 1462 | meteoriteContainer.style.display = 'flex'; 1463 | } 1464 | const classicContainer = document.getElementById('classic-typing-container'); 1465 | if (classicContainer) { 1466 | classicContainer.style.display = 'none'; 1467 | } 1468 | const racingContainer = document.getElementById('racing-track-container'); 1469 | if (racingContainer) { 1470 | racingContainer.style.display = 'none'; 1471 | } 1472 | // Restart meteorite rain game 1473 | if (currentGame && currentGame.startGame) { 1474 | setTimeout(() => { 1475 | if (currentGame && currentGame.startGame) { 1476 | currentGame.startGame(); 1477 | } 1478 | }, 100); 1479 | } 1480 | } else { 1481 | const typingTextContainer = document.querySelector('.typing-text-container'); 1482 | if (typingTextContainer) { 1483 | typingTextContainer.style.display = 'block'; 1484 | } 1485 | const racingContainer = document.getElementById('racing-track-container'); 1486 | if (racingContainer) { 1487 | racingContainer.style.display = 'none'; 1488 | } 1489 | const meteoriteContainer = document.getElementById('meteorite-rain-container'); 1490 | if (meteoriteContainer) { 1491 | meteoriteContainer.style.display = 'none'; 1492 | } 1493 | } 1494 | 1495 | if (completionScreen) { 1496 | completionScreen.style.display = 'none'; 1497 | } 1498 | if (statsDashboard) { 1499 | statsDashboard.style.display = 'none'; 1500 | } 1501 | 1502 | // Show real-time stats again if configured 1503 | if (realtimeStatsContainer) { 1504 | updateRealtimeStats(); 1505 | } 1506 | 1507 | // Show keyboard-stats-wrapper again 1508 | const keyboardStatsWrapper = document.querySelector('.keyboard-stats-wrapper'); 1509 | if (keyboardStatsWrapper) { 1510 | keyboardStatsWrapper.style.display = 'block'; 1511 | } 1512 | 1513 | // Show keyboard again if it was enabled 1514 | if (keyboardContainer && keyboardEnabled) { 1515 | keyboardContainer.classList.add('visible'); 1516 | } 1517 | 1518 | // Show the restart button again 1519 | if (restartButton && restartButton.parentElement) { 1520 | restartButton.parentElement.style.display = 'block'; 1521 | } 1522 | 1523 | renderText(); 1524 | setStatus('Ready'); 1525 | 1526 | // Focus the appropriate input after a short delay 1527 | setTimeout(() => { 1528 | if (isMeteoriteRain) { 1529 | const meteoriteInput = document.getElementById('meteorite-typing-input'); 1530 | if (meteoriteInput) { 1531 | meteoriteInput.focus(); 1532 | } 1533 | } else if (hiddenInput) { 1534 | hiddenInput.focus(); 1535 | } 1536 | }, 50); 1537 | } 1538 | 1539 | // Calculate real-time statistics (while typing) 1540 | function calculateRealtimeStats() { 1541 | // Calculate chars typed and total 1542 | const charsTyped = typedText.length; 1543 | const charsTotal = originalText.length; 1544 | 1545 | if (startTime === null) { 1546 | return { 1547 | speed: 0, 1548 | accuracy: 0, 1549 | time: 0, 1550 | errors: 0, 1551 | errorsLeft: 0, 1552 | chars: { typed: charsTyped, total: charsTotal } 1553 | }; 1554 | } 1555 | 1556 | const currentTime = Date.now(); 1557 | const totalTimeSeconds = (currentTime - startTime) / 1000; 1558 | const totalTimeMinutes = totalTimeSeconds / 60; 1559 | 1560 | // Count errors left (unfixed incorrect characters) 1561 | let errorsLeft = 0; 1562 | for (let i = 0; i < charStates.length; i++) { 1563 | if (charStates[i] === 'incorrect') { 1564 | errorsLeft++; 1565 | } 1566 | } 1567 | 1568 | // Calculate accuracy: (correct inputs / total inputs) * 100 1569 | const correctInputs = totalInputs - totalErrors; 1570 | const accuracy = totalInputs > 0 ? (correctInputs / totalInputs) * 100 : 0; 1571 | 1572 | // Calculate words per minute 1573 | // Count words by splitting on whitespace 1574 | const wordsTyped = originalText.trim().split(/\s+/).filter(word => word.length > 0).length; 1575 | const wpm = totalTimeMinutes > 0 ? wordsTyped / totalTimeMinutes : 0; 1576 | 1577 | return { 1578 | speed: wpm, 1579 | accuracy: accuracy, 1580 | time: totalTimeSeconds, 1581 | errors: totalErrors, 1582 | errorsLeft: errorsLeft, 1583 | chars: { typed: charsTyped, total: charsTotal } 1584 | }; 1585 | } 1586 | 1587 | // Update real-time stats display 1588 | function updateRealtimeStats() { 1589 | if (!realtimeStatsContainer) return; 1590 | 1591 | // Check if realTimeStats is configured and has items 1592 | if (!config.realTimeStats || !Array.isArray(config.realTimeStats) || config.realTimeStats.length === 0) { 1593 | realtimeStatsContainer.style.display = 'none'; 1594 | // Clear interval if stats are disabled 1595 | if (realtimeStatsInterval) { 1596 | clearInterval(realtimeStatsInterval); 1597 | realtimeStatsInterval = null; 1598 | } 1599 | return; 1600 | } 1601 | 1602 | const stats = calculateRealtimeStats(); 1603 | realtimeStatsContainer.style.display = 'flex'; 1604 | 1605 | // Clear existing content 1606 | realtimeStatsContainer.innerHTML = ''; 1607 | 1608 | // Map of stat keys to display info 1609 | const statMap = { 1610 | speed: { label: 'WPM', value: stats.speed, format: (v) => v.toFixed(1) }, 1611 | accuracy: { label: 'Accuracy', value: stats.accuracy, format: (v) => v.toFixed(1) + '%' }, 1612 | time: { label: 'Time', value: stats.time, format: (v) => { 1613 | if (v < 60) { 1614 | return v.toFixed(1) + 's'; 1615 | } else { 1616 | const minutes = Math.floor(v / 60); 1617 | const seconds = (v % 60).toFixed(1); 1618 | return `${minutes}m ${seconds}s`; 1619 | } 1620 | }}, 1621 | errors: { label: 'Errors', value: stats.errors, format: (v) => Math.round(v).toString() }, 1622 | errorsLeft: { label: 'Errors Left', value: stats.errorsLeft, format: (v) => Math.round(v).toString() }, 1623 | chars: { label: 'Chars', value: stats.chars, format: (v) => `${v.typed}/${v.total}` } 1624 | }; 1625 | 1626 | // Create stat items for each configured stat 1627 | config.realTimeStats.forEach(statKey => { 1628 | const statInfo = statMap[statKey]; 1629 | if (!statInfo) return; // Skip invalid stat keys 1630 | 1631 | const statItem = document.createElement('div'); 1632 | statItem.className = 'realtime-stat-item'; 1633 | 1634 | const statLabel = document.createElement('span'); 1635 | statLabel.className = 'realtime-stat-label'; 1636 | statLabel.textContent = statInfo.label; 1637 | 1638 | const statValue = document.createElement('span'); 1639 | statValue.className = 'realtime-stat-value'; 1640 | statValue.textContent = statInfo.format(statInfo.value); 1641 | 1642 | statItem.appendChild(statLabel); 1643 | statItem.appendChild(statValue); 1644 | realtimeStatsContainer.appendChild(statItem); 1645 | }); 1646 | 1647 | // Start periodic updates if typing has started and interval not already running 1648 | if (startTime !== null && !realtimeStatsInterval) { 1649 | realtimeStatsInterval = setInterval(() => { 1650 | updateRealtimeStats(); 1651 | }, 100); // Update every 100ms for smooth time updates 1652 | } 1653 | } 1654 | 1655 | function calculateStatistics() { 1656 | console.log('Calculating statistics...'); 1657 | console.log('startTime:', startTime, 'totalInputs:', totalInputs, 'totalErrors:', totalErrors); 1658 | 1659 | if (startTime === null) { 1660 | console.log('No typing started, returning null'); 1661 | return null; // No typing started 1662 | } 1663 | 1664 | const endTime = Date.now(); 1665 | const totalTimeSeconds = (endTime - startTime) / 1000; 1666 | const totalTimeMinutes = totalTimeSeconds / 60; 1667 | 1668 | // Count errors left (unfixed incorrect characters) 1669 | let errorsLeft = 0; 1670 | for (let i = 0; i < charStates.length; i++) { 1671 | if (charStates[i] === 'incorrect') { 1672 | errorsLeft++; 1673 | } 1674 | } 1675 | 1676 | // Calculate accuracy: (correct inputs / total inputs) * 100 1677 | const correctInputs = totalInputs - totalErrors; 1678 | const accuracy = totalInputs > 0 ? (correctInputs / totalInputs) * 100 : 0; 1679 | 1680 | // Calculate words per minute 1681 | // Count words by splitting on whitespace 1682 | const wordsTyped = originalText.trim().split(/\s+/).filter(word => word.length > 0).length; 1683 | const wpm = totalTimeMinutes > 0 ? wordsTyped / totalTimeMinutes : 0; 1684 | 1685 | const stats = { 1686 | totalErrors: totalErrors, 1687 | errorsLeft: errorsLeft, 1688 | totalTime: totalTimeSeconds, 1689 | accuracy: accuracy, 1690 | speed: wpm 1691 | }; 1692 | 1693 | console.log('Calculated statistics:', stats); 1694 | return stats; 1695 | } 1696 | 1697 | async function saveStatistics(stats) { 1698 | console.log('saveStatistics called with:', stats); 1699 | try { 1700 | // Get win/lose status for racing games or score for meteorite rain 1701 | let statusLine = ''; 1702 | if (config.gameType === 'racing' && currentGame && currentGame.playerWon !== null) { 1703 | const status = currentGame.playerWon ? 'win' : 'lose'; 1704 | statusLine = `Status: ${status}\n\n`; 1705 | } else if (config.gameType === 'meteoriteRain' && currentGame instanceof MeteoriteRainGame) { 1706 | const score = currentGame.getScore ? currentGame.getScore() : 0; 1707 | statusLine = `Score: ${score}\n\n`; 1708 | } 1709 | 1710 | // Format statistics text 1711 | const statsText = `${statusLine}Typing Statistics 1712 | ================== 1713 | 1714 | Total Errors Made: ${stats.totalErrors} 1715 | Errors Left (Unfixed): ${stats.errorsLeft} 1716 | Total Time: ${stats.totalTime.toFixed(2)} seconds 1717 | Accuracy: ${stats.accuracy.toFixed(2)}% 1718 | Speed: ${stats.speed.toFixed(2)} words per minute 1719 | 1720 | Generated: ${new Date().toLocaleString()} 1721 | `; 1722 | 1723 | console.log('Sending stats to server:', statsText); 1724 | const response = await fetch('/save-stats', { 1725 | method: 'POST', 1726 | headers: { 1727 | 'Content-Type': 'text/plain', 1728 | }, 1729 | body: statsText 1730 | }); 1731 | 1732 | console.log('Server response status:', response.status); 1733 | 1734 | if (response.ok) { 1735 | const result = await response.json(); 1736 | console.log('Statistics saved to client/stats.txt', result); 1737 | } else { 1738 | const errorText = await response.text(); 1739 | console.error('Failed to save statistics:', response.status, errorText); 1740 | } 1741 | } catch (error) { 1742 | console.error('Error saving statistics:', error); 1743 | } 1744 | } 1745 | 1746 | // Parse stats from stats.txt file 1747 | function parseStatsText(statsText) { 1748 | const stats = {}; 1749 | const lines = statsText.split('\n'); 1750 | 1751 | for (const line of lines) { 1752 | if (line.includes('Total Errors Made:')) { 1753 | const match = line.match(/Total Errors Made:\s*(\d+)/); 1754 | if (match) stats.totalErrors = parseInt(match[1], 10); 1755 | } else if (line.includes('Errors Left (Unfixed):')) { 1756 | const match = line.match(/Errors Left \(Unfixed\):\s*(\d+)/); 1757 | if (match) stats.errorsLeft = parseInt(match[1], 10); 1758 | } else if (line.includes('Total Time:')) { 1759 | const match = line.match(/Total Time:\s*([\d.]+)\s*seconds/); 1760 | if (match) stats.totalTime = parseFloat(match[1]); 1761 | } else if (line.includes('Accuracy:')) { 1762 | const match = line.match(/Accuracy:\s*([\d.]+)%/); 1763 | if (match) stats.accuracy = parseFloat(match[1]); 1764 | } else if (line.includes('Speed:')) { 1765 | const match = line.match(/Speed:\s*([\d.]+)\s*words per minute/); 1766 | if (match) stats.speed = parseFloat(match[1]); 1767 | } 1768 | } 1769 | 1770 | return stats; 1771 | } 1772 | 1773 | // Load and display stats dashboard 1774 | async function showStatsDashboard() { 1775 | // Hide typing container 1776 | const typingTextContainer = document.getElementById('classic-typing-container'); 1777 | if (typingTextContainer) { 1778 | typingTextContainer.style.display = 'none'; 1779 | } 1780 | 1781 | // Hide racing track 1782 | const racingTrackContainer = document.getElementById('racing-track-container'); 1783 | if (racingTrackContainer) { 1784 | racingTrackContainer.style.display = 'none'; 1785 | } 1786 | 1787 | // Hide meteorite rain container 1788 | const meteoriteRainContainer = document.getElementById('meteorite-rain-container'); 1789 | if (meteoriteRainContainer) { 1790 | meteoriteRainContainer.style.display = 'none'; 1791 | } 1792 | 1793 | // Hide the restart button when dashboard is shown 1794 | if (restartButton && restartButton.parentElement) { 1795 | restartButton.parentElement.style.display = 'none'; 1796 | } 1797 | 1798 | // Hide keyboard when dashboard is shown 1799 | if (keyboardContainer) { 1800 | keyboardContainer.classList.remove('visible'); 1801 | } 1802 | 1803 | // Hide real-time stats when dashboard is shown 1804 | if (realtimeStatsContainer) { 1805 | realtimeStatsContainer.style.display = 'none'; 1806 | } 1807 | 1808 | // Hide keyboard-stats-wrapper when dashboard is shown 1809 | const keyboardStatsWrapper = document.querySelector('.keyboard-stats-wrapper'); 1810 | if (keyboardStatsWrapper) { 1811 | keyboardStatsWrapper.style.display = 'none'; 1812 | } 1813 | 1814 | // Hide completion screen if visible 1815 | if (completionScreen) { 1816 | completionScreen.style.display = 'none'; 1817 | } 1818 | 1819 | try { 1820 | const response = await fetch('./stats.txt'); 1821 | let stats = null; 1822 | 1823 | if (response.ok) { 1824 | const statsText = await response.text(); 1825 | stats = parseStatsText(statsText); 1826 | } else { 1827 | console.warn('Stats file not found, using default values'); 1828 | // Create default stats object with zeros 1829 | stats = { 1830 | totalErrors: 0, 1831 | errorsLeft: 0, 1832 | totalTime: 0, 1833 | accuracy: 0, 1834 | speed: 0 1835 | }; 1836 | } 1837 | 1838 | // Update dashboard header based on game type 1839 | const dashboardHeader = statsDashboard ? statsDashboard.querySelector('.stats-dashboard-header h2') : null; 1840 | if (dashboardHeader) { 1841 | if (config.gameType === 'meteoriteRain' && currentGame instanceof MeteoriteRainGame) { 1842 | // Show final score for meteorite rain 1843 | const score = currentGame.getScore ? currentGame.getScore() : 0; 1844 | dashboardHeader.textContent = `Final Score: ${score}`; 1845 | } else if (config.gameType === 'racing' && currentGame && currentGame.playerWon !== null) { 1846 | if (currentGame.playerWon === true) { 1847 | dashboardHeader.textContent = 'Victory 🏅'; 1848 | } else if (currentGame.playerWon === false) { 1849 | dashboardHeader.textContent = 'You lost! 😢'; 1850 | } else { 1851 | dashboardHeader.textContent = 'Typing Statistics'; // Fallback 1852 | } 1853 | } else { 1854 | dashboardHeader.textContent = 'Typing Statistics'; // Default for non-racing games 1855 | } 1856 | } 1857 | 1858 | // Update dashboard with stats 1859 | const speedEl = document.getElementById('stat-speed'); 1860 | const accuracyEl = document.getElementById('stat-accuracy'); 1861 | const timeEl = document.getElementById('stat-time'); 1862 | const errorsEl = document.getElementById('stat-errors'); 1863 | const errorsLeftEl = document.getElementById('stat-errors-left'); 1864 | 1865 | if (speedEl) speedEl.textContent = stats.speed ? stats.speed.toFixed(1) : '0'; 1866 | if (accuracyEl) accuracyEl.textContent = stats.accuracy ? stats.accuracy.toFixed(1) + '%' : '0%'; 1867 | if (timeEl) { 1868 | const timeValue = stats.totalTime || 0; 1869 | if (timeValue < 60) { 1870 | timeEl.textContent = timeValue.toFixed(1) + 's'; 1871 | } else { 1872 | const minutes = Math.floor(timeValue / 60); 1873 | const seconds = (timeValue % 60).toFixed(1); 1874 | timeEl.textContent = `${minutes}m ${seconds}s`; 1875 | } 1876 | } 1877 | if (errorsEl) errorsEl.textContent = stats.totalErrors || 0; 1878 | if (errorsLeftEl) errorsLeftEl.textContent = stats.errorsLeft || 0; 1879 | 1880 | // Show dashboard 1881 | if (statsDashboard) { 1882 | statsDashboard.style.display = 'flex'; 1883 | } 1884 | 1885 | if (hiddenInput) { 1886 | hiddenInput.blur(); 1887 | } 1888 | } catch (error) { 1889 | console.error('Error loading stats:', error); 1890 | // Fall back to simple completion screen 1891 | // Keyboard is already hidden above 1892 | if (completionScreen) { 1893 | completionScreen.style.display = 'flex'; 1894 | } 1895 | } 1896 | } 1897 | 1898 | function showCompletionScreen() { 1899 | console.log('showCompletionScreen called'); 1900 | 1901 | // Hide stats dashboard if visible 1902 | if (statsDashboard) { 1903 | statsDashboard.style.display = 'none'; 1904 | } 1905 | 1906 | if (!completionScreen) { 1907 | console.error('Completion screen element not found!'); 1908 | return; 1909 | } 1910 | 1911 | // Hide typing container 1912 | const typingTextContainer = document.getElementById('classic-typing-container'); 1913 | if (typingTextContainer) { 1914 | typingTextContainer.style.display = 'none'; 1915 | } 1916 | 1917 | // Hide racing track 1918 | const racingTrackContainer = document.getElementById('racing-track-container'); 1919 | if (racingTrackContainer) { 1920 | racingTrackContainer.style.display = 'none'; 1921 | } 1922 | 1923 | // Hide meteorite rain container 1924 | const meteoriteRainContainer = document.getElementById('meteorite-rain-container'); 1925 | if (meteoriteRainContainer) { 1926 | meteoriteRainContainer.style.display = 'none'; 1927 | } 1928 | 1929 | // Hide keyboard when completion screen is shown 1930 | if (keyboardContainer) { 1931 | keyboardContainer.classList.remove('visible'); 1932 | } 1933 | 1934 | // Hide real-time stats when completion screen is shown 1935 | if (realtimeStatsContainer) { 1936 | realtimeStatsContainer.style.display = 'none'; 1937 | } 1938 | 1939 | // Hide keyboard-stats-wrapper when completion screen is shown 1940 | const keyboardStatsWrapper = document.querySelector('.keyboard-stats-wrapper'); 1941 | if (keyboardStatsWrapper) { 1942 | keyboardStatsWrapper.style.display = 'none'; 1943 | } 1944 | 1945 | // Hide the restart button when completion screen is shown 1946 | if (restartButton && restartButton.parentElement) { 1947 | restartButton.parentElement.style.display = 'none'; 1948 | } 1949 | 1950 | // Calculate and save statistics 1951 | console.log('About to calculate statistics...'); 1952 | let stats = calculateStatistics(); 1953 | console.log('Statistics result:', stats); 1954 | 1955 | // For meteorite rain games, create stats even if calculateStatistics returns null 1956 | const isMeteoriteRainGame = config.gameType === 'meteoriteRain' && currentGame instanceof MeteoriteRainGame; 1957 | if (isMeteoriteRainGame && (!stats || stats === null)) { 1958 | // Create minimal stats for meteorite rain using game's own timing 1959 | const gameStartTime = currentGame.gameStartTime; 1960 | const endTime = Date.now(); 1961 | const totalTimeSeconds = gameStartTime ? (endTime - gameStartTime) / 1000 : 0; 1962 | 1963 | stats = { 1964 | totalErrors: 0, 1965 | errorsLeft: 0, 1966 | totalTime: totalTimeSeconds, 1967 | accuracy: 100, // Not applicable for word-based game, but set to 100 1968 | speed: 0 // Not applicable for word-based game 1969 | }; 1970 | console.log('Created stats for meteorite rain game:', stats); 1971 | } 1972 | 1973 | // For racing game or meteorite rain, show dashboard even if stats are null 1974 | const isRacingGame = config.gameType === 'racing' && currentGame; 1975 | const shouldShowDashboard = config.showStats === true || (isRacingGame && currentGame.playerWon !== null) || isMeteoriteRainGame; 1976 | 1977 | if (stats) { 1978 | console.log('Calling saveStatistics...'); 1979 | saveStatistics(stats).then(() => { 1980 | // After saving, check if we should show stats dashboard 1981 | if (shouldShowDashboard) { 1982 | // Wait a bit for the file to be written, then show dashboard 1983 | setTimeout(() => { 1984 | showStatsDashboard(); 1985 | }, 200); 1986 | } else { 1987 | // Show simple completion screen 1988 | // Ensure real-time stats are hidden 1989 | if (realtimeStatsContainer) { 1990 | realtimeStatsContainer.style.display = 'none'; 1991 | } 1992 | completionScreen.style.display = 'flex'; 1993 | if (hiddenInput) { 1994 | hiddenInput.blur(); 1995 | } 1996 | } 1997 | }); 1998 | } else { 1999 | console.log('No statistics to save (stats is null)'); 2000 | // For racing game or meteorite rain, still show dashboard 2001 | if (shouldShowDashboard) { 2002 | setTimeout(() => { 2003 | showStatsDashboard(); 2004 | }, 200); 2005 | } else { 2006 | // Show simple completion screen 2007 | // Ensure real-time stats are hidden 2008 | if (realtimeStatsContainer) { 2009 | realtimeStatsContainer.style.display = 'none'; 2010 | } 2011 | completionScreen.style.display = 'flex'; 2012 | if (hiddenInput) { 2013 | hiddenInput.blur(); 2014 | } 2015 | } 2016 | } 2017 | } 2018 | 2019 | async function initialize() { 2020 | // Load config first 2021 | await loadConfig(); 2022 | 2023 | textContainer = document.getElementById('typing-text'); 2024 | hiddenInput = document.getElementById('hidden-input'); 2025 | completionScreen = document.getElementById('completion-screen'); 2026 | statsDashboard = document.getElementById('stats-dashboard'); 2027 | restartButton = document.getElementById('btn-restart'); 2028 | startOverButton = document.getElementById('btn-start-over'); 2029 | statsStartOverButton = document.getElementById('btn-stats-start-over'); 2030 | realtimeStatsContainer = document.getElementById('realtime-stats-container'); 2031 | 2032 | if (!hiddenInput) { 2033 | console.error('Required elements not found'); 2034 | return; 2035 | } 2036 | 2037 | // Initialize game based on config 2038 | initializeGame(); 2039 | 2040 | // Initialize keyboard 2041 | initializeKeyboard(); 2042 | 2043 | // Set up event listeners 2044 | hiddenInput.addEventListener('input', handleInput); 2045 | hiddenInput.addEventListener('keydown', handleKeyDown); 2046 | 2047 | // Set up event listeners for meteorite rain input 2048 | const meteoriteInput = document.getElementById('meteorite-typing-input'); 2049 | if (meteoriteInput) { 2050 | meteoriteInput.addEventListener('input', handleInput); 2051 | meteoriteInput.addEventListener('keydown', handleKeyDown); 2052 | } 2053 | 2054 | if (restartButton) { 2055 | restartButton.addEventListener('click', restart); 2056 | } 2057 | 2058 | if (startOverButton) { 2059 | startOverButton.addEventListener('click', restart); 2060 | } 2061 | 2062 | if (statsStartOverButton) { 2063 | statsStartOverButton.addEventListener('click', restart); 2064 | } 2065 | 2066 | // Focus the input when clicking on the text container or racing track 2067 | const typingTextContainer = document.querySelector('.typing-text-container'); 2068 | const racingTrackContainer = document.getElementById('racing-track-container'); 2069 | 2070 | if (typingTextContainer) { 2071 | typingTextContainer.addEventListener('click', () => { 2072 | const isCompletionVisible = completionScreen && completionScreen.style.display === 'flex'; 2073 | const isStatsVisible = statsDashboard && statsDashboard.style.display === 'flex'; 2074 | if (hiddenInput && !isCompletionVisible && !isStatsVisible) { 2075 | hiddenInput.focus(); 2076 | } 2077 | }); 2078 | } 2079 | 2080 | if (racingTrackContainer) { 2081 | racingTrackContainer.addEventListener('click', () => { 2082 | const isCompletionVisible = completionScreen && completionScreen.style.display === 'flex'; 2083 | const isStatsVisible = statsDashboard && statsDashboard.style.display === 'flex'; 2084 | if (hiddenInput && !isCompletionVisible && !isStatsVisible) { 2085 | hiddenInput.focus(); 2086 | } 2087 | }); 2088 | } 2089 | 2090 | // Focus meteorite input when clicking on play area 2091 | const meteoritePlayArea = document.getElementById('meteorite-play-area'); 2092 | if (meteoritePlayArea) { 2093 | meteoritePlayArea.addEventListener('click', () => { 2094 | const isCompletionVisible = completionScreen && completionScreen.style.display === 'flex'; 2095 | const isStatsVisible = statsDashboard && statsDashboard.style.display === 'flex'; 2096 | const meteoriteInput = document.getElementById('meteorite-typing-input'); 2097 | if (meteoriteInput && !isCompletionVisible && !isStatsVisible) { 2098 | meteoriteInput.focus(); 2099 | } 2100 | }); 2101 | } 2102 | 2103 | // Load the text 2104 | await loadText(); 2105 | 2106 | // Extract words for meteorite rain game after text is loaded 2107 | if (config.gameType === 'meteoriteRain' && currentGame instanceof MeteoriteRainGame) { 2108 | if (currentGame.extractWords) { 2109 | currentGame.extractWords(); 2110 | } 2111 | } 2112 | 2113 | // Update track dimensions after text is loaded (for racing game) 2114 | if (currentGame && currentGame.updateTrackDimensions) { 2115 | // Wait for layout to settle 2116 | setTimeout(() => { 2117 | if (currentGame && currentGame.updateTrackDimensions) { 2118 | currentGame.updateTrackDimensions(); 2119 | } 2120 | }, 100); 2121 | } 2122 | 2123 | // Initialize real-time stats display 2124 | updateRealtimeStats(); 2125 | 2126 | // Focus the appropriate input after a short delay 2127 | setTimeout(() => { 2128 | const isCompletionVisible = completionScreen && completionScreen.style.display === 'flex'; 2129 | const isStatsVisible = statsDashboard && statsDashboard.style.display === 'flex'; 2130 | if (isCompletionVisible || isStatsVisible) return; 2131 | 2132 | if (config.gameType === 'meteoriteRain') { 2133 | const meteoriteInput = document.getElementById('meteorite-typing-input'); 2134 | if (meteoriteInput) { 2135 | meteoriteInput.focus(); 2136 | } 2137 | } else if (hiddenInput) { 2138 | hiddenInput.focus(); 2139 | } 2140 | }, 100); 2141 | 2142 | // Handle window resize for racing game 2143 | let resizeTimeout = null; 2144 | window.addEventListener('resize', () => { 2145 | if (resizeTimeout) { 2146 | clearTimeout(resizeTimeout); 2147 | } 2148 | resizeTimeout = setTimeout(() => { 2149 | if (currentGame && currentGame.updateTrackDimensions) { 2150 | currentGame.updateTrackDimensions(); 2151 | } 2152 | }, 250); 2153 | }); 2154 | } 2155 | 2156 | // Initialize when DOM is ready 2157 | if (document.readyState === 'loading') { 2158 | document.addEventListener('DOMContentLoaded', initialize); 2159 | } else { 2160 | initialize(); 2161 | } 2162 | })(); 2163 | --------------------------------------------------------------------------------