├── client ├── text-to-input.txt ├── config.json ├── stats.txt ├── 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 ├── .gitignore ├── .gitmodules ├── vite.config.js ├── package.json ├── .github └── workflows │ └── build-release.yml ├── extract_solution.py ├── LICENSE ├── AGENTS.md ├── README.md ├── server.js └── BESPOKE-TEMPLATE.md /client/text-to-input.txt: -------------------------------------------------------------------------------- 1 | Test text to input! 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist/ 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "client/design-system"] 2 | path = client/design-system 3 | url = https://github.com/CodeSignal/learn_bespoke-design-system.git 4 | -------------------------------------------------------------------------------- /client/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "keyboard": true, 3 | "availableKeys": [], 4 | "showStats": true, 5 | "realTimeStats": ["time", "chars"], 6 | "keyTips": true 7 | } 8 | -------------------------------------------------------------------------------- /client/stats.txt: -------------------------------------------------------------------------------- 1 | Typing Statistics 2 | ================== 3 | 4 | Total Errors Made: 3 5 | Errors Left (Unfixed): 3 6 | Total Time: 2.81 seconds 7 | Accuracy: 84.21% 8 | Speed: 85.29 words per minute 9 | 10 | Generated: 03/12/2025, 10:19:24 11 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | const status = document.getElementById('status'); 4 | let websocket = null; 5 | 6 | function setStatus(msg) { 7 | status.textContent = msg; 8 | } 9 | 10 | // Initialize WebSocket connection 11 | function initializeWebSocket() { 12 | const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; 13 | const host = window.location.host; 14 | const wsUrl = `${protocol}//${host}/ws`; 15 | 16 | try { 17 | websocket = new WebSocket(wsUrl); 18 | 19 | websocket.onopen = function(event) { 20 | console.log('WebSocket connected'); 21 | setStatus('Ready (WebSocket connected)'); 22 | }; 23 | 24 | websocket.onmessage = function(event) { 25 | try { 26 | const data = JSON.parse(event.data); 27 | if (data.type === 'message' && data.message) { 28 | alert(data.message); 29 | } 30 | } catch (error) { 31 | console.error('Error parsing WebSocket message:', error); 32 | } 33 | }; 34 | 35 | websocket.onclose = function(event) { 36 | console.log('WebSocket disconnected'); 37 | setStatus('Ready (WebSocket disconnected)'); 38 | 39 | // Attempt to reconnect after 3 seconds 40 | setTimeout(() => { 41 | console.log('Attempting to reconnect WebSocket...'); 42 | initializeWebSocket(); 43 | }, 3000); 44 | }; 45 | 46 | websocket.onerror = function(error) { 47 | console.error('WebSocket error:', error); 48 | setStatus('Ready (WebSocket error)'); 49 | }; 50 | 51 | } catch (error) { 52 | console.error('Failed to create WebSocket connection:', error); 53 | setStatus('Ready (WebSocket unavailable)'); 54 | } 55 | } 56 | 57 | // Load help content and initialize modal 58 | async function initializeHelpModal() { 59 | try { 60 | const response = await fetch('./help-content.html'); 61 | const helpContent = await response.text(); 62 | 63 | // Initialize help modal with actual content 64 | HelpModal.init({ 65 | triggerSelector: '#btn-help', 66 | content: helpContent, 67 | theme: 'auto' 68 | }); 69 | 70 | setStatus('Ready'); 71 | } catch (error) { 72 | console.error('Failed to load help content:', error); 73 | // Fallback to placeholder content 74 | HelpModal.init({ 75 | triggerSelector: '#btn-help', 76 | content: '

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

', 77 | theme: 'auto' 78 | }); 79 | setStatus('Ready (help content unavailable)'); 80 | } 81 | } 82 | 83 | // Initialize both help modal and WebSocket when DOM is ready 84 | function initialize() { 85 | initializeHelpModal(); 86 | initializeWebSocket(); 87 | } 88 | 89 | if (document.readyState === 'loading') { 90 | document.addEventListener('DOMContentLoaded', initialize); 91 | } else { 92 | initialize(); 93 | } 94 | })(); 95 | -------------------------------------------------------------------------------- /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 | # Print all statistics 64 | print("Typing Statistics") 65 | print("=" * 50) 66 | print() 67 | 68 | if 'total_errors' in stats: 69 | print(f"Total Errors Made: {stats['total_errors']}") 70 | 71 | if 'errors_left' in stats: 72 | print(f"Errors Left (Unfixed): {stats['errors_left']}") 73 | 74 | if 'total_time' in stats: 75 | time_value = stats['total_time'] 76 | if time_value < 60: 77 | print(f"Total Time: {time_value:.2f} seconds") 78 | else: 79 | minutes = int(time_value // 60) 80 | seconds = time_value % 60 81 | print(f"Total Time: {minutes}m {seconds:.2f}s") 82 | 83 | if 'accuracy' in stats: 84 | print(f"Accuracy: {stats['accuracy']:.2f}%") 85 | 86 | if 'speed' in stats: 87 | print(f"Speed: {stats['speed']:.2f} words per minute") 88 | 89 | if 'generated' in stats: 90 | print(f"Generated: {stats['generated']}") 91 | 92 | print() 93 | 94 | 95 | if __name__ == '__main__': 96 | extract_stats() 97 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 |
Ready
34 | 35 |
36 | 37 | 38 |
39 |
40 |
41 |
42 |
43 | 51 |
52 |
53 |

Completed

54 |

Great job! You've finished typing the text.

55 | 56 |
57 |
58 |
59 |

Typing Statistics

60 |

Your performance summary

61 |
62 |
63 |
64 |
65 | 0 66 |
67 |
WPM
68 |
69 |
70 |
71 | 0% 72 |
73 |
Accuracy
74 |
75 |
76 |
77 | 0s 78 |
79 |
Total time
80 |
81 |
82 |
83 | 0 84 |
85 |
Total errors
86 |
87 |
88 |
89 | 0 90 |
91 |
Errors left
92 |
93 |
94 | 97 |
98 |
99 | 106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 126 | 127 | 128 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | .bespoke .typing-container { 10 | position: relative; 11 | width: 100%; 12 | height: 100%; 13 | display: flex; 14 | flex-direction: column; 15 | align-items: center; 16 | justify-content: flex-start; 17 | padding: var(--UI-Spacing-spacing-xl); 18 | overflow: hidden; /* Prevent scrolling */ 19 | min-height: 0; /* Allow flexbox to shrink */ 20 | } 21 | 22 | /* Keyboard and Stats Wrapper */ 23 | .bespoke .keyboard-stats-wrapper { 24 | position: relative; 25 | width: 100%; 26 | max-width: 900px; 27 | margin: var(--UI-Spacing-spacing-md) auto 0; 28 | flex: 0 0 auto; /* Don't grow, don't shrink */ 29 | min-height: 0; /* Allow flexbox to shrink */ 30 | } 31 | 32 | /* Real-time Stats Container */ 33 | .bespoke .realtime-stats-container { 34 | display: none; 35 | position: absolute; 36 | left: 0; 37 | bottom: 0; 38 | flex-direction: column; 39 | gap: 2px; 40 | padding: var(--UI-Spacing-spacing-md); 41 | background: var(--Colors-Box-Background-Secondary); 42 | border: none; 43 | border-radius: var(--UI-Radius-radius-m); 44 | width: 140px; 45 | margin-right: var(--UI-Spacing-spacing-xl); 46 | z-index: 1; 47 | } 48 | 49 | .bespoke .realtime-stat-item { 50 | display: flex; 51 | flex-direction: row; 52 | align-items: center; 53 | justify-content: space-between; 54 | gap: var(--UI-Spacing-spacing-xs); 55 | padding: 0; 56 | min-width: 0; 57 | } 58 | 59 | .bespoke .realtime-stat-label { 60 | font-size: var(--Fonts-Body-Default-xs); 61 | color: var(--Colors-Text-Body-Medium); 62 | text-transform: uppercase; 63 | letter-spacing: 0.5px; 64 | font-weight: 500; 65 | white-space: nowrap; 66 | flex-shrink: 0; 67 | } 68 | 69 | .bespoke .realtime-stat-value { 70 | font-size: var(--Fonts-Headlines-sm); 71 | font-weight: 600; 72 | color: var(--Colors-Primary-Default); 73 | font-family: var(--heading-family); 74 | text-align: right; 75 | flex-shrink: 0; 76 | min-width: 60px; 77 | font-variant-numeric: tabular-nums; 78 | } 79 | 80 | .bespoke .typing-text-container { 81 | flex: 1 1 auto; 82 | position: relative; 83 | overflow: hidden; /* Remove scrolling - content must fit */ 84 | padding: var(--UI-Spacing-spacing-mxl); 85 | background: transparent; 86 | font-family: var(--body-family); 87 | font-size: var(--Fonts-Body-Default-xxxl); 88 | line-height: 140%; 89 | cursor: text; 90 | max-width: 900px; 91 | width: 100%; 92 | margin: var(--UI-Spacing-spacing-lg) auto 0; 93 | min-height: 0; /* Allow flexbox to shrink */ 94 | display: flex; 95 | flex-direction: column; 96 | } 97 | 98 | .bespoke .typing-text { 99 | white-space: pre-wrap; 100 | word-wrap: break-word; 101 | user-select: none; 102 | text-align: left; 103 | width: 100%; 104 | overflow: hidden; /* Prevent text from causing scroll */ 105 | flex: 1 1 auto; 106 | min-height: 0; 107 | } 108 | 109 | .bespoke .typing-text .char-pending { 110 | color: var(--Colors-Text-Body-Light); 111 | } 112 | 113 | .bespoke .typing-text .char-correct { 114 | color: var(--Colors-Text-Body-Strongest); 115 | } 116 | 117 | .bespoke .typing-text .char-incorrect { 118 | color: var(--Colors-Alert-Error-Default); 119 | } 120 | 121 | .bespoke .typing-text .cursor-position { 122 | position: relative; 123 | } 124 | 125 | .bespoke .typing-text .cursor-position::before { 126 | content: ''; 127 | position: absolute; 128 | left: -1px; 129 | top: 0; 130 | bottom: 0; 131 | width: 2px; 132 | background-color: var(--Colors-Primary-Default); 133 | animation: blink 1s step-end infinite; 134 | pointer-events: none; 135 | } 136 | 137 | @keyframes blink { 138 | 0%, 50% { 139 | opacity: 1; 140 | } 141 | 51%, 100% { 142 | opacity: 0; 143 | } 144 | } 145 | 146 | .bespoke .hidden-input { 147 | position: absolute; 148 | opacity: 0; 149 | pointer-events: none; 150 | width: 0; 151 | height: 0; 152 | border: none; 153 | padding: 0; 154 | margin: 0; 155 | } 156 | 157 | .bespoke .restart-button-container { 158 | position: fixed; 159 | bottom: var(--UI-Spacing-spacing-mxl); 160 | left: var(--UI-Spacing-spacing-mxl); 161 | z-index: 1000; 162 | } 163 | 164 | .bespoke .restart-button-container button { 165 | display: flex; 166 | align-items: center; 167 | justify-content: center; 168 | width: 40px; 169 | height: 40px; 170 | padding: 0; 171 | border-radius: var(--UI-Radius-radius-s); 172 | } 173 | 174 | .bespoke .restart-button-container button:active, 175 | .bespoke .restart-button-container button.active { 176 | box-shadow: none; 177 | border-bottom: none; 178 | color: var(--Colors-Buttons-Text-Primary-Hover); 179 | } 180 | 181 | .bespoke .restart-button-container button:focus, 182 | .bespoke .restart-button-container button.focus { 183 | box-shadow: none; 184 | border-bottom: none; 185 | outline: none; 186 | color: var(--Colors-Buttons-Text-Primary-Hover); 187 | } 188 | 189 | .bespoke .restart-button-container button svg { 190 | width: 20px; 191 | height: 20px; 192 | } 193 | 194 | .bespoke .completion-screen { 195 | display: none; 196 | position: fixed; 197 | top: 50%; 198 | left: 50%; 199 | transform: translate(-50%, -50%); 200 | flex-direction: column; 201 | align-items: center; 202 | justify-content: center; 203 | gap: var(--UI-Spacing-spacing-xl); 204 | padding: var(--UI-Spacing-spacing-xxxl); 205 | background: var(--Colors-Box-Background); 206 | border-radius: var(--UI-Radius-radius-m); 207 | border: 1px solid var(--Colors-Stroke-Default); 208 | text-align: center; 209 | z-index: 1000; 210 | min-width: 400px; 211 | max-width: 90vw; 212 | max-height: 90vh; 213 | overflow-y: auto; 214 | box-shadow: 0 3px 2px 0 var(--Colors-Shadow-Card); 215 | } 216 | 217 | .bespoke .completion-screen h2 { 218 | font-size: var(--Fonts-Headlines-md); 219 | margin: 0; 220 | color: var(--Colors-Text-Body-Strongest); 221 | font-family: var(--heading-family); 222 | font-weight: 500; 223 | } 224 | 225 | .bespoke .completion-screen p { 226 | font-size: var(--Fonts-Body-Default-lg); 227 | color: var(--Colors-Text-Body-Medium); 228 | margin: 0; 229 | } 230 | 231 | /* Stats Dashboard Styles */ 232 | .bespoke .stats-dashboard { 233 | display: none; 234 | position: fixed; 235 | top: 50%; 236 | left: 50%; 237 | transform: translate(-50%, -50%); 238 | flex-direction: column; 239 | align-items: center; 240 | justify-content: flex-start; 241 | gap: var(--UI-Spacing-spacing-xl); 242 | padding: var(--UI-Spacing-spacing-xl); 243 | background: var(--Colors-Backgrounds-Main-Default); 244 | text-align: center; 245 | z-index: 1000; 246 | min-width: 600px; 247 | max-width: 90vw; 248 | max-height: 90vh; 249 | overflow-y: auto; 250 | } 251 | 252 | .bespoke .stats-dashboard-header { 253 | width: 100%; 254 | margin-bottom: var(--UI-Spacing-spacing-lg); 255 | } 256 | 257 | .bespoke .stats-dashboard-header h2 { 258 | font-size: var(--Fonts-Headlines-xl); 259 | margin: 0 0 var(--UI-Spacing-spacing-xs) 0; 260 | color: var(--Colors-Text-Body-Strongest); 261 | font-family: var(--heading-family); 262 | font-weight: 500; 263 | } 264 | 265 | .bespoke .stats-subtitle { 266 | font-size: var(--Fonts-Body-Default-md); 267 | color: var(--Colors-Text-Body-Medium); 268 | margin: 0; 269 | } 270 | 271 | .bespoke .stats-grid { 272 | display: grid; 273 | grid-template-columns: repeat(5, 1fr); 274 | gap: var(--UI-Spacing-spacing-mxl); 275 | width: 100%; 276 | margin: var(--UI-Spacing-spacing-xl) 0; 277 | } 278 | 279 | .bespoke .stat-card { 280 | display: flex; 281 | flex-direction: column; 282 | align-items: center; 283 | justify-content: center; 284 | padding: var(--UI-Spacing-spacing-mxl); 285 | background: var(--Colors-Box-Background-Secondary); 286 | border: 1px solid var(--Colors-Stroke-Default); 287 | border-radius: var(--UI-Radius-radius-m); 288 | transition: transform 0.2s ease, box-shadow 0.2s ease; 289 | } 290 | 291 | .bespoke .stat-card:hover { 292 | transform: translateY(-2px); 293 | box-shadow: 0 4px 8px 0 var(--Colors-Shadow-Card); 294 | } 295 | 296 | .bespoke .stat-value-container { 297 | display: flex; 298 | align-items: center; 299 | justify-content: center; 300 | margin-bottom: var(--UI-Spacing-spacing-xs); 301 | } 302 | 303 | .bespoke .stat-value { 304 | font-size: var(--Fonts-Headlines-sm); 305 | font-weight: 600; 306 | color: var(--Colors-Primary-Default); 307 | font-family: var(--heading-family); 308 | } 309 | 310 | .bespoke .stat-label { 311 | font-size: var(--Fonts-Body-Default-sm); 312 | color: var(--Colors-Text-Body-Medium); 313 | text-transform: uppercase; 314 | letter-spacing: 0.5px; 315 | } 316 | 317 | .bespoke .stats-dashboard-footer { 318 | width: 100%; 319 | margin-top: var(--UI-Spacing-spacing-lg); 320 | } 321 | 322 | /* Mini Keyboard Styles */ 323 | .bespoke .keyboard-container { 324 | display: none; 325 | width: 100%; 326 | padding: var(--UI-Spacing-spacing-md) var(--UI-Spacing-spacing-lg) var(--UI-Spacing-spacing-sm) var(--UI-Spacing-spacing-lg); 327 | background: transparent; 328 | overflow: visible; /* Allow key tip glow to be visible */ 329 | } 330 | 331 | .bespoke .keyboard-container.visible { 332 | display: block; 333 | } 334 | 335 | .bespoke .keyboard-container .keyboard { 336 | margin: 0 auto; /* Center the keyboard */ 337 | } 338 | 339 | .bespoke .keyboard-container.visible { 340 | display: block; 341 | } 342 | 343 | .bespoke .keyboard { 344 | display: flex; 345 | flex-direction: column; 346 | gap: var(--UI-Spacing-spacing-xs); 347 | font-family: var(--body-family); 348 | font-size: 12px; 349 | user-select: none; 350 | } 351 | 352 | .bespoke .keyboard-row { 353 | display: flex; 354 | justify-content: center; 355 | gap: var(--UI-Spacing-spacing-xs); 356 | } 357 | 358 | .bespoke .keyboard-key { 359 | display: flex; 360 | align-items: center; 361 | justify-content: center; 362 | min-width: 28px; 363 | height: 32px; 364 | padding: 0 var(--UI-Spacing-spacing-sm); 365 | background: var(--Colors-Box-Background-Secondary); 366 | border: 1px solid var(--Colors-Stroke-Default); 367 | border-radius: var(--UI-Radius-radius-s); 368 | color: var(--Colors-Text-Body-Strongest); 369 | font-weight: 500; 370 | transition: all 0.15s ease; 371 | box-shadow: 0 1px 2px 0 var(--Colors-Shadow-Card); 372 | } 373 | 374 | .bespoke .keyboard-key.space { 375 | min-width: 200px; 376 | } 377 | 378 | .bespoke .keyboard-key.enter { 379 | min-width: 60px; 380 | } 381 | 382 | .bespoke .keyboard-key.shift { 383 | min-width: 70px; 384 | } 385 | 386 | .bespoke .keyboard-key.backspace { 387 | min-width: 70px; 388 | } 389 | 390 | .bespoke .keyboard-key.tab { 391 | min-width: 50px; 392 | } 393 | 394 | .bespoke .keyboard-key.caps { 395 | min-width: 60px; 396 | } 397 | 398 | .bespoke .keyboard-key.active { 399 | background: color-mix(in srgb, var(--Colors-Primary-Default) 30%, transparent); 400 | border-color: var(--Colors-Primary-Default); 401 | transform: scale(0.95); 402 | } 403 | 404 | .bespoke .keyboard-key.active-error { 405 | background: color-mix(in srgb, var(--Colors-Alert-Error-Default) 30%, transparent); 406 | border-color: var(--Colors-Alert-Error-Default); 407 | transform: scale(0.95); 408 | } 409 | 410 | .bespoke .keyboard-key.unavailable { 411 | background: var(--Colors-Box-Background); 412 | border-color: var(--Colors-Stroke-Light); 413 | color: var(--Colors-Text-Body-Light); 414 | opacity: 0.5; 415 | cursor: not-allowed; 416 | } 417 | 418 | .bespoke .keyboard-key.key-tip { 419 | background: color-mix(in srgb, var(--Colors-Primary-Default) 40%, transparent); 420 | border-color: var(--Colors-Primary-Default); 421 | animation: keyTipPulse 1.5s ease-in-out infinite; 422 | box-shadow: 0 0 8px 2px color-mix(in srgb, var(--Colors-Primary-Default) 50%, transparent); 423 | } 424 | 425 | @keyframes keyTipPulse { 426 | 0%, 100% { 427 | transform: scale(1); 428 | box-shadow: 0 0 8px 2px color-mix(in srgb, var(--Colors-Primary-Default) 50%, transparent); 429 | } 430 | 50% { 431 | transform: scale(1.05); 432 | box-shadow: 0 0 12px 4px color-mix(in srgb, var(--Colors-Primary-Default) 70%, transparent); 433 | } 434 | } 435 | 436 | /* Responsive Design */ 437 | @media (max-width: 1024px) { 438 | .bespoke .keyboard-stats-wrapper { 439 | margin: var(--UI-Spacing-spacing-lg) auto 0; 440 | } 441 | 442 | .bespoke .realtime-stats-container { 443 | gap: 2px; 444 | padding: var(--UI-Spacing-spacing-sm); 445 | min-width: 130px; 446 | } 447 | 448 | .bespoke .realtime-stat-item { 449 | padding: 0; 450 | gap: var(--UI-Spacing-spacing-sm); 451 | } 452 | 453 | .bespoke .realtime-stat-label { 454 | font-size: var(--Fonts-Body-Default-xxs); 455 | } 456 | 457 | .bespoke .realtime-stat-value { 458 | font-size: var(--Fonts-Headlines-xs); 459 | } 460 | 461 | .bespoke .typing-text-container { 462 | font-size: var(--Fonts-Body-Default-xxl); 463 | padding: var(--UI-Spacing-spacing-xl); 464 | margin: var(--UI-Spacing-spacing-md) auto 0; 465 | } 466 | 467 | .bespoke .keyboard-container { 468 | padding: var(--UI-Spacing-spacing-md); 469 | } 470 | 471 | .bespoke .keyboard { 472 | font-size: 11px; 473 | } 474 | 475 | .bespoke .keyboard-key { 476 | min-width: 24px; 477 | height: 28px; 478 | padding: 0 var(--UI-Spacing-spacing-xs); 479 | } 480 | 481 | .bespoke .keyboard-key.space { 482 | min-width: 150px; 483 | } 484 | 485 | .bespoke .keyboard-key.enter { 486 | min-width: 50px; 487 | } 488 | 489 | .bespoke .keyboard-key.shift { 490 | min-width: 60px; 491 | } 492 | 493 | .bespoke .keyboard-key.backspace { 494 | min-width: 60px; 495 | } 496 | 497 | .bespoke .keyboard-key.tab { 498 | min-width: 40px; 499 | } 500 | 501 | .bespoke .keyboard-key.caps { 502 | min-width: 50px; 503 | } 504 | } 505 | 506 | @media (max-width: 768px) { 507 | .bespoke .keyboard-stats-wrapper { 508 | margin: var(--UI-Spacing-spacing-md) auto 0; 509 | } 510 | 511 | .bespoke .realtime-stats-container { 512 | position: static; 513 | width: 100%; 514 | max-width: 100%; 515 | min-width: auto; 516 | gap: 2px; 517 | padding: var(--UI-Spacing-spacing-sm); 518 | margin-bottom: var(--UI-Spacing-spacing-md); 519 | } 520 | 521 | .bespoke .realtime-stat-item { 522 | padding: 0; 523 | gap: var(--UI-Spacing-spacing-sm); 524 | } 525 | 526 | .bespoke .keyboard-container { 527 | width: 100%; 528 | } 529 | 530 | .bespoke .typing-container { 531 | padding: var(--UI-Spacing-spacing-md); 532 | } 533 | 534 | .bespoke .typing-text-container { 535 | font-size: var(--Fonts-Body-Default-xl); 536 | padding: var(--UI-Spacing-spacing-lg); 537 | margin: var(--UI-Spacing-spacing-sm) auto 0; 538 | } 539 | 540 | .bespoke .completion-screen { 541 | min-width: 90vw; 542 | max-width: 90vw; 543 | padding: var(--UI-Spacing-spacing-xl); 544 | gap: var(--UI-Spacing-spacing-lg); 545 | } 546 | 547 | .bespoke .completion-screen h2 { 548 | font-size: var(--Fonts-Headlines-sm); 549 | } 550 | 551 | .bespoke .completion-screen p { 552 | font-size: var(--Fonts-Body-Default-md); 553 | } 554 | 555 | .bespoke .stats-dashboard { 556 | min-width: 90vw; 557 | max-width: 90vw; 558 | padding: var(--UI-Spacing-spacing-xl); 559 | gap: var(--UI-Spacing-spacing-lg); 560 | } 561 | 562 | .bespoke .stats-dashboard-header h2 { 563 | font-size: var(--Fonts-Headlines-lg); 564 | } 565 | 566 | .bespoke .stats-subtitle { 567 | font-size: var(--Fonts-Body-Default-sm); 568 | } 569 | 570 | .bespoke .stats-grid { 571 | grid-template-columns: repeat(5, 1fr); 572 | gap: var(--UI-Spacing-spacing-m); 573 | } 574 | 575 | .bespoke .stat-card { 576 | padding: var(--UI-Spacing-spacing-m); 577 | } 578 | 579 | .bespoke .stat-value { 580 | font-size: var(--Fonts-Headlines-xs); 581 | } 582 | 583 | .bespoke .stat-label { 584 | font-size: var(--Fonts-Body-Default-xs); 585 | } 586 | 587 | .bespoke .keyboard-container { 588 | padding: var(--UI-Spacing-spacing-sm); 589 | margin: var(--UI-Spacing-spacing-md) auto 0; 590 | } 591 | 592 | .bespoke .keyboard { 593 | font-size: 10px; 594 | gap: 2px; 595 | } 596 | 597 | .bespoke .keyboard-row { 598 | gap: 2px; 599 | } 600 | 601 | .bespoke .keyboard-key { 602 | min-width: 20px; 603 | height: 24px; 604 | padding: 0 4px; 605 | font-size: 10px; 606 | } 607 | 608 | .bespoke .keyboard-key.space { 609 | min-width: 120px; 610 | } 611 | 612 | .bespoke .keyboard-key.enter { 613 | min-width: 40px; 614 | } 615 | 616 | .bespoke .keyboard-key.shift { 617 | min-width: 50px; 618 | } 619 | 620 | .bespoke .keyboard-key.backspace { 621 | min-width: 50px; 622 | } 623 | 624 | .bespoke .keyboard-key.tab { 625 | min-width: 35px; 626 | } 627 | 628 | .bespoke .keyboard-key.caps { 629 | min-width: 40px; 630 | } 631 | 632 | .bespoke .restart-button-container { 633 | bottom: var(--UI-Spacing-spacing-md); 634 | left: var(--UI-Spacing-spacing-md); 635 | } 636 | 637 | .bespoke .restart-button-container button { 638 | width: 36px; 639 | height: 36px; 640 | } 641 | 642 | .bespoke .restart-button-container button svg { 643 | width: 18px; 644 | height: 18px; 645 | } 646 | } 647 | 648 | @media (max-width: 480px) { 649 | .bespoke .keyboard-stats-wrapper { 650 | margin: var(--UI-Spacing-spacing-sm) auto 0; 651 | } 652 | 653 | .bespoke .realtime-stats-container { 654 | position: static; 655 | width: 100%; 656 | max-width: 100%; 657 | min-width: auto; 658 | gap: 2px; 659 | padding: var(--UI-Spacing-spacing-xs); 660 | margin-bottom: var(--UI-Spacing-spacing-sm); 661 | } 662 | 663 | .bespoke .realtime-stat-item { 664 | padding: 0; 665 | gap: var(--UI-Spacing-spacing-xs); 666 | } 667 | 668 | .bespoke .typing-container { 669 | padding: var(--UI-Spacing-spacing-sm); 670 | } 671 | 672 | .bespoke .typing-text-container { 673 | font-size: var(--Fonts-Body-Default-lg); 674 | padding: var(--UI-Spacing-spacing-md); 675 | margin: var(--UI-Spacing-spacing-xs) auto 0; 676 | } 677 | 678 | .bespoke .completion-screen { 679 | padding: var(--UI-Spacing-spacing-lg); 680 | gap: var(--UI-Spacing-spacing-md); 681 | } 682 | 683 | .bespoke .stats-dashboard { 684 | padding: var(--UI-Spacing-spacing-lg); 685 | gap: var(--UI-Spacing-spacing-md); 686 | } 687 | 688 | .bespoke .stats-grid { 689 | grid-template-columns: repeat(5, 1fr); 690 | gap: var(--UI-Spacing-spacing-s); 691 | } 692 | 693 | .bespoke .stat-card { 694 | padding: var(--UI-Spacing-spacing-s); 695 | } 696 | 697 | .bespoke .stat-value { 698 | font-size: 0.875rem; 699 | } 700 | 701 | .bespoke .keyboard-container { 702 | padding: var(--UI-Spacing-spacing-xs); 703 | margin: var(--UI-Spacing-spacing-sm) auto 0; 704 | } 705 | 706 | .bespoke .keyboard { 707 | font-size: 8px; 708 | gap: 1px; 709 | } 710 | 711 | .bespoke .keyboard-row { 712 | gap: 1px; 713 | } 714 | 715 | .bespoke .keyboard-key { 716 | min-width: 16px; 717 | height: 20px; 718 | padding: 0 2px; 719 | font-size: 8px; 720 | } 721 | 722 | .bespoke .keyboard-key.space { 723 | min-width: 100px; 724 | } 725 | 726 | .bespoke .keyboard-key.enter { 727 | min-width: 35px; 728 | } 729 | 730 | .bespoke .keyboard-key.shift { 731 | min-width: 40px; 732 | } 733 | 734 | .bespoke .keyboard-key.backspace { 735 | min-width: 40px; 736 | } 737 | 738 | .bespoke .keyboard-key.tab { 739 | min-width: 30px; 740 | } 741 | 742 | .bespoke .keyboard-key.caps { 743 | min-width: 35px; 744 | } 745 | } 746 | -------------------------------------------------------------------------------- /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: [], keyTips: false }; 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 | let keyTipsEnabled = false; 32 | let currentTipKeyElement = null; 33 | let currentTipShiftElement = null; // Track which shift key is highlighted 34 | 35 | // Real-time stats update interval 36 | let realtimeStatsInterval = null; 37 | 38 | function setStatus(msg) { 39 | const status = document.getElementById('status'); 40 | if (status) { 41 | status.textContent = msg; 42 | } 43 | } 44 | 45 | // Load configuration 46 | async function loadConfig() { 47 | try { 48 | const response = await fetch('./config.json'); 49 | if (!response.ok) { 50 | console.warn('Config file not found, using defaults'); 51 | return; 52 | } 53 | config = await response.json(); 54 | 55 | // Normalize available keys to lowercase for fast lookup 56 | // Empty array means all keys are available 57 | if (config.availableKeys && Array.isArray(config.availableKeys) && config.availableKeys.length > 0) { 58 | availableKeysSet = new Set(config.availableKeys.map(key => key.toLowerCase())); 59 | } else { 60 | availableKeysSet = new Set(); // Empty set means all keys available 61 | } 62 | } catch (error) { 63 | console.warn('Error loading config:', error); 64 | } 65 | } 66 | 67 | // Keyboard layout definition 68 | const keyboardLayout = [ 69 | ['`', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '=', 'backspace'], 70 | ['tab', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '[', ']', '\\'], 71 | ['caps', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', "'", 'enter'], 72 | ['shift', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '/', 'shift'], 73 | ['space'] 74 | ]; 75 | 76 | // Map special keys to display names 77 | const keyDisplayNames = { 78 | 'backspace': '⌫', 79 | 'tab': 'Tab', 80 | 'caps': 'Caps', 81 | 'enter': 'Enter', 82 | 'shift': 'Shift', 83 | 'space': 'Space' 84 | }; 85 | 86 | // Check if a key is available for typing 87 | // Accepts both character values (from input) and KeyboardEvent.key values 88 | function isKeyAvailable(key) { 89 | // Space, comma, dot, backspace, and enter are ALWAYS available 90 | const keyLower = key.toLowerCase(); 91 | if (key === ' ' || key === '\u00A0' || key === ',' || key === '.' || 92 | key === 'Backspace' || key === '\b' || key === 'Enter' || key === 'Return' || 93 | key === '\n' || key === '\r' || 94 | keyLower === 'space' || keyLower === 'comma' || keyLower === 'dot' || 95 | keyLower === 'backspace' || keyLower === 'enter') { 96 | return true; 97 | } 98 | 99 | // If no available keys are configured, all keys are available 100 | if (availableKeysSet.size === 0) { 101 | return true; 102 | } 103 | 104 | // Handle KeyboardEvent.key values (e.g., "Tab") 105 | if (key === 'Tab' || keyLower === 'tab') { 106 | return availableKeysSet.has('tab'); 107 | } 108 | 109 | // Handle character values (from input events) 110 | if (key === '\t') { 111 | return availableKeysSet.has('tab'); 112 | } 113 | 114 | // For regular keys, normalize to lowercase and check 115 | // Handle both single characters and KeyboardEvent.key values 116 | const normalizedKey = key.length === 1 ? key.toLowerCase() : keyLower; 117 | return availableKeysSet.has(normalizedKey); 118 | } 119 | 120 | // Get key element by character 121 | function getKeyElement(char) { 122 | if (!keyboardContainer) return null; 123 | 124 | // Normalize character 125 | const normalizedChar = char.toLowerCase(); 126 | 127 | // Handle special keys 128 | if (char === ' ') { 129 | return keyboardContainer.querySelector('[data-key="space"]'); 130 | } 131 | if (char === '\n' || char === '\r') { 132 | return keyboardContainer.querySelector('[data-key="enter"]'); 133 | } 134 | if (char === '\t') { 135 | return keyboardContainer.querySelector('[data-key="tab"]'); 136 | } 137 | 138 | // Find regular key 139 | return keyboardContainer.querySelector(`[data-key="${normalizedChar}"]`); 140 | } 141 | 142 | // Map shift symbols to their base keys 143 | const shiftSymbolMap = { 144 | '!': '1', '@': '2', '#': '3', '$': '4', '%': '5', 145 | '^': '6', '&': '7', '*': '8', '(': '9', ')': '0', 146 | '_': '-', '+': '=', '{': '[', '}': ']', '|': '\\', 147 | ':': ';', '"': "'", '<': ',', '>': '.', '?': '/', '~': '`' 148 | }; 149 | 150 | // Get the base key for a character (maps shift symbols to their base keys) 151 | function getBaseKey(char) { 152 | // If it's a shift symbol, return the base key 153 | if (shiftSymbolMap[char]) { 154 | return shiftSymbolMap[char]; 155 | } 156 | // For uppercase letters, return lowercase 157 | if (char >= 'A' && char <= 'Z') { 158 | return char.toLowerCase(); 159 | } 160 | // Otherwise return as-is 161 | return char; 162 | } 163 | 164 | // Check if a character requires Shift to type 165 | function requiresShift(char) { 166 | // Check if it's an uppercase letter 167 | if (char >= 'A' && char <= 'Z') { 168 | return true; 169 | } 170 | 171 | // Check if it's a special symbol that requires Shift 172 | if (shiftSymbolMap[char]) { 173 | return true; 174 | } 175 | 176 | return false; 177 | } 178 | 179 | // Determine if a key is on the left side of the keyboard 180 | // Returns true for left side, false for right side 181 | // Based on standard QWERTY layout: 182 | // Left: `, 1-5, tab, q-t, caps, a-g, shift, z-b 183 | // Right: 6-0, -, =, y-p, [, ], \, h-l, ;, ', n-/, shift 184 | function isKeyOnLeftSide(char) { 185 | const normalizedChar = char.toLowerCase(); 186 | 187 | // Handle special keys 188 | if (normalizedChar === 'tab' || normalizedChar === 'caps' || normalizedChar === 'shift') { 189 | // These keys span or are on left side 190 | return true; 191 | } 192 | 193 | // Number row: left side is `, 1-5 194 | if (normalizedChar === '`' || (normalizedChar >= '1' && normalizedChar <= '5')) { 195 | return true; 196 | } 197 | 198 | // Top letter row: left side is q-t 199 | if (normalizedChar >= 'q' && normalizedChar <= 't') { 200 | return true; 201 | } 202 | 203 | // Middle letter row: left side is a-g 204 | if (normalizedChar >= 'a' && normalizedChar <= 'g') { 205 | return true; 206 | } 207 | 208 | // Bottom letter row: left side is z-b 209 | if (normalizedChar >= 'z' && normalizedChar <= 'b') { 210 | return true; 211 | } 212 | 213 | // Everything else is on the right side 214 | return false; 215 | } 216 | 217 | // Get shift key element (left or right) 218 | function getShiftKeyElement(isLeft) { 219 | if (!keyboardContainer) return null; 220 | 221 | // Find shift key by data attribute 222 | const shiftSide = isLeft ? 'left' : 'right'; 223 | return keyboardContainer.querySelector(`[data-key="shift"][data-shift-side="${shiftSide}"]`); 224 | } 225 | 226 | // Update the key tip highlight (shows which key to press next) 227 | function updateKeyTip() { 228 | // Only update if keyTips mode is enabled and keyboard is visible 229 | if (!keyTipsEnabled || !keyboardEnabled || !keyboardContainer) { 230 | return; 231 | } 232 | 233 | // Clear previous tip 234 | if (currentTipKeyElement) { 235 | currentTipKeyElement.classList.remove('key-tip'); 236 | currentTipKeyElement = null; 237 | } 238 | 239 | // Clear previous shift tip 240 | if (currentTipShiftElement) { 241 | currentTipShiftElement.classList.remove('key-tip'); 242 | currentTipShiftElement = null; 243 | } 244 | 245 | // Find the next character to type 246 | const nextCharIndex = typedText.length; 247 | if (nextCharIndex >= originalText.length) { 248 | // All characters typed, no tip needed 249 | return; 250 | } 251 | 252 | const nextChar = originalText[nextCharIndex]; 253 | if (!nextChar) { 254 | return; 255 | } 256 | 257 | // Get the base key for highlighting (maps shift symbols to their base keys) 258 | const baseKey = getBaseKey(nextChar); 259 | 260 | // Get the key element for the base key 261 | const keyElement = getKeyElement(baseKey); 262 | if (keyElement) { 263 | currentTipKeyElement = keyElement; 264 | keyElement.classList.add('key-tip'); 265 | } 266 | 267 | // Check if shift is needed and highlight appropriate shift key 268 | if (requiresShift(nextChar)) { 269 | // Determine which side of keyboard the key is on 270 | const charForSideCheck = baseKey.toLowerCase(); 271 | const isLeftSide = isKeyOnLeftSide(charForSideCheck); 272 | 273 | // Highlight opposite shift: left side keys use right shift, right side keys use left shift 274 | const shiftElement = getShiftKeyElement(!isLeftSide); 275 | if (shiftElement) { 276 | currentTipShiftElement = shiftElement; 277 | shiftElement.classList.add('key-tip'); 278 | } 279 | } 280 | } 281 | 282 | // Highlight a key on the keyboard 283 | function highlightKey(char, isError = false) { 284 | // Skip regular highlighting if keyTips mode is enabled 285 | if (keyTipsEnabled) { 286 | return; 287 | } 288 | 289 | // Don't highlight unavailable keys 290 | if (!isKeyAvailable(char)) { 291 | return; 292 | } 293 | 294 | // Clear previous highlight 295 | if (activeKeyElement) { 296 | activeKeyElement.classList.remove('active', 'active-error'); 297 | } 298 | 299 | // Clear timeout if exists 300 | if (activeKeyTimeout) { 301 | clearTimeout(activeKeyTimeout); 302 | } 303 | 304 | const keyElement = getKeyElement(char); 305 | if (keyElement) { 306 | activeKeyElement = keyElement; 307 | if (isError) { 308 | keyElement.classList.add('active-error'); 309 | } else { 310 | keyElement.classList.add('active'); 311 | } 312 | 313 | // Remove highlight after animation 314 | activeKeyTimeout = setTimeout(() => { 315 | if (keyElement) { 316 | keyElement.classList.remove('active', 'active-error'); 317 | } 318 | activeKeyElement = null; 319 | }, 200); 320 | } 321 | } 322 | 323 | // Render the keyboard 324 | function renderKeyboard() { 325 | if (!keyboardContainer) return; 326 | 327 | const keyboard = document.createElement('div'); 328 | keyboard.className = 'keyboard'; 329 | 330 | let shiftKeyIndex = 0; // Track which shift key we're rendering (0 = left, 1 = right) 331 | 332 | keyboardLayout.forEach(row => { 333 | const rowElement = document.createElement('div'); 334 | rowElement.className = 'keyboard-row'; 335 | 336 | row.forEach(key => { 337 | const keyElement = document.createElement('div'); 338 | const normalizedKey = key.toLowerCase(); 339 | keyElement.className = 'keyboard-key'; 340 | keyElement.setAttribute('data-key', normalizedKey); 341 | 342 | // Add data attribute to distinguish left vs right shift 343 | if (key === 'shift') { 344 | const shiftSide = shiftKeyIndex === 0 ? 'left' : 'right'; 345 | keyElement.setAttribute('data-shift-side', shiftSide); 346 | shiftKeyIndex++; 347 | } 348 | 349 | // Check if this key is available (use isKeyAvailable to ensure space, comma, dot are always available) 350 | const isAvailable = isKeyAvailable(key); 351 | if (!isAvailable) { 352 | keyElement.classList.add('unavailable'); 353 | } 354 | 355 | // Add special class for certain keys 356 | if (key === 'space' || key === 'enter' || key === 'shift' || 357 | key === 'backspace' || key === 'tab' || key === 'caps') { 358 | keyElement.classList.add(key); 359 | } 360 | 361 | // Set display text 362 | if (keyDisplayNames[key]) { 363 | keyElement.textContent = keyDisplayNames[key]; 364 | } else { 365 | keyElement.textContent = key.toUpperCase(); 366 | } 367 | 368 | rowElement.appendChild(keyElement); 369 | }); 370 | 371 | keyboard.appendChild(rowElement); 372 | }); 373 | 374 | keyboardContainer.innerHTML = ''; 375 | keyboardContainer.appendChild(keyboard); 376 | } 377 | 378 | // Initialize keyboard 379 | function initializeKeyboard() { 380 | keyboardContainer = document.getElementById('keyboard-container'); 381 | if (!keyboardContainer) return; 382 | 383 | keyboardEnabled = config.keyboard === true; 384 | keyTipsEnabled = config.keyTips === true; // Defaults to false if undefined or not set 385 | 386 | if (keyboardEnabled) { 387 | renderKeyboard(); 388 | keyboardContainer.classList.add('visible'); 389 | // Update key tip after keyboard is rendered 390 | if (keyTipsEnabled) { 391 | updateKeyTip(); 392 | } 393 | } else { 394 | keyboardContainer.classList.remove('visible'); 395 | } 396 | } 397 | 398 | async function loadText() { 399 | try { 400 | setStatus('Loading...'); 401 | const response = await fetch('./text-to-input.txt'); 402 | if (!response.ok) { 403 | throw new Error('Failed to load text file'); 404 | } 405 | originalText = await response.text(); 406 | // Trim trailing whitespace/newlines but keep the original for display 407 | originalText = originalText.trimEnd(); 408 | 409 | // Initialize character states 410 | charStates.length = 0; 411 | for (let i = 0; i < originalText.length; i++) { 412 | charStates.push('pending'); 413 | } 414 | 415 | renderText(); 416 | setStatus('Ready'); 417 | } catch (error) { 418 | console.error('Error loading text:', error); 419 | setStatus('Failed to load data'); 420 | if (textContainer) { 421 | textContainer.innerHTML = '

Error: Could not load text file.

'; 422 | } 423 | } 424 | } 425 | 426 | function renderText() { 427 | if (!textContainer) return; 428 | 429 | // Check if completed - show completion screen when all characters are typed 430 | // (regardless of whether they're correct or not) 431 | const typedTrimmed = typedText.trimEnd(); 432 | const originalTrimmed = originalText.trimEnd(); 433 | 434 | // Check if all characters are typed (even if there are mistakes) 435 | if (typedTrimmed.length === originalTrimmed.length) { 436 | console.log('Completion detected! Showing completion screen.'); 437 | console.log('Typed length:', typedTrimmed.length, 'Original length:', originalTrimmed.length); 438 | showCompletionScreen(); 439 | return; 440 | } 441 | 442 | // Hide completion screen if visible and show typing container 443 | if (completionScreen) { 444 | completionScreen.style.display = 'none'; 445 | } 446 | const typingTextContainer = document.querySelector('.typing-text-container'); 447 | if (typingTextContainer) { 448 | typingTextContainer.style.display = 'block'; 449 | } 450 | 451 | let html = ''; 452 | const currentPosition = typedText.length; 453 | 454 | for (let i = 0; i < originalText.length; i++) { 455 | const char = originalText[i]; 456 | const state = charStates[i]; 457 | let className = 'char-'; 458 | 459 | if (i < typedText.length) { 460 | // Character has been typed 461 | if (state === 'incorrect') { 462 | className += 'incorrect'; 463 | } else { 464 | className += 'correct'; 465 | } 466 | } else { 467 | // Character not yet typed 468 | className += 'pending'; 469 | } 470 | 471 | // Handle special characters that need escaping 472 | let displayChar = char; 473 | if (char === ' ') { 474 | displayChar = '\u00A0'; // Non-breaking space 475 | } else if (char === '\n') { 476 | displayChar = '
'; 477 | } else { 478 | displayChar = escapeHtml(char); 479 | } 480 | 481 | // Add cursor class to the character at the typing position 482 | if (i === currentPosition) { 483 | className += ' cursor-position'; 484 | } 485 | 486 | html += `${displayChar}`; 487 | } 488 | 489 | // If all characters are typed, add a cursor position marker at the end 490 | if (currentPosition === originalText.length) { 491 | html += '\u00A0'; 492 | } 493 | 494 | textContainer.innerHTML = html; 495 | 496 | // Update key tip if enabled 497 | updateKeyTip(); 498 | } 499 | 500 | function escapeHtml(text) { 501 | const div = document.createElement('div'); 502 | div.textContent = text; 503 | return div.innerHTML; 504 | } 505 | 506 | function handleInput(e) { 507 | let input = e.target.value; 508 | 509 | // Filter out unavailable keys if availableKeys is configured 510 | if (availableKeysSet.size > 0) { 511 | let filteredInput = ''; 512 | for (let i = 0; i < input.length; i++) { 513 | const char = input[i]; 514 | if (isKeyAvailable(char)) { 515 | filteredInput += char; 516 | } 517 | } 518 | input = filteredInput; 519 | e.target.value = input; 520 | } 521 | 522 | // Start timer on first keypress 523 | if (startTime === null && input.length > 0) { 524 | startTime = Date.now(); 525 | } 526 | 527 | // Prevent typing beyond the original text length 528 | if (input.length > originalText.length) { 529 | input = input.slice(0, originalText.length); 530 | e.target.value = input; 531 | } 532 | 533 | const inputLength = input.length; 534 | const typedLength = typedText.length; 535 | 536 | // Handle typing forward 537 | if (inputLength > typedLength) { 538 | const newChars = input.slice(typedLength); 539 | for (let i = 0; i < newChars.length; i++) { 540 | const charIndex = typedLength + i; 541 | if (charIndex >= originalText.length) { 542 | break; 543 | } 544 | 545 | const expectedChar = originalText[charIndex]; 546 | const typedChar = newChars[i]; 547 | 548 | totalInputs++; // Track total inputs 549 | 550 | const isError = typedChar !== expectedChar; 551 | if (isError) { 552 | charStates[charIndex] = 'incorrect'; 553 | totalErrors++; // Track total errors (even if later fixed) 554 | } else { 555 | charStates[charIndex] = 'correct'; 556 | } 557 | 558 | // Highlight keyboard key 559 | if (keyboardEnabled) { 560 | highlightKey(typedChar, isError); 561 | } 562 | } 563 | typedText = input; 564 | } 565 | // Handle backspace/delete 566 | else if (inputLength < typedLength) { 567 | typedText = input; 568 | // Reset states for characters that are no longer typed 569 | for (let i = inputLength; i < originalText.length; i++) { 570 | if (i < charStates.length) { 571 | charStates[i] = 'pending'; 572 | } 573 | } 574 | 575 | // Highlight backspace key (only if available) 576 | if (keyboardEnabled && isKeyAvailable('backspace')) { 577 | highlightKey('backspace', false); 578 | } 579 | } 580 | 581 | renderText(); 582 | updateRealtimeStats(); 583 | // updateKeyTip is called in renderText, so no need to call it here 584 | } 585 | 586 | function handleKeyDown(e) { 587 | // Handle Enter key - check availability but let textarea handle insertion 588 | if (e.key === 'Enter' || e.key === 'Return') { 589 | if (!isKeyAvailable('\n')) { 590 | e.preventDefault(); // Prevent if not available 591 | return; 592 | } 593 | 594 | // Check if we can still type (not beyond original text length) 595 | if (hiddenInput.value.length >= originalText.length) { 596 | e.preventDefault(); // Can't type beyond original text 597 | return; 598 | } 599 | 600 | // Let the browser handle the newline insertion naturally 601 | // Highlight keyboard key if enabled 602 | if (keyboardEnabled) { 603 | // Use setTimeout to highlight after the newline is inserted 604 | setTimeout(() => { 605 | highlightKey('\n', false); 606 | }, 0); 607 | } 608 | 609 | // The input event will fire naturally, no need to manually trigger 610 | return; 611 | } 612 | 613 | // Handle Tab key - manually insert tab character 614 | if (e.key === 'Tab') { 615 | e.preventDefault(); // Prevent tab from moving focus 616 | 617 | if (!isKeyAvailable('\t')) { 618 | return; // Key not available, don't insert 619 | } 620 | 621 | // Check if we can still type (not beyond original text length) 622 | if (hiddenInput.value.length >= originalText.length) { 623 | return; // Can't type beyond original text 624 | } 625 | 626 | // Get current cursor position 627 | const cursorPos = hiddenInput.selectionStart || hiddenInput.value.length; 628 | 629 | // Insert tab at cursor position 630 | const currentValue = hiddenInput.value; 631 | const newValue = currentValue.slice(0, cursorPos) + '\t' + currentValue.slice(cursorPos); 632 | 633 | // Update input value 634 | hiddenInput.value = newValue; 635 | 636 | // Move cursor after the inserted tab 637 | setTimeout(() => { 638 | hiddenInput.setSelectionRange(cursorPos + 1, cursorPos + 1); 639 | }, 0); 640 | 641 | // Highlight keyboard key if enabled 642 | if (keyboardEnabled) { 643 | highlightKey('\t', false); 644 | } 645 | 646 | // Manually trigger input event to process the tab 647 | const inputEvent = new Event('input', { bubbles: true }); 648 | hiddenInput.dispatchEvent(inputEvent); 649 | 650 | return; 651 | } 652 | 653 | // Prevent unavailable keys from being typed 654 | if (availableKeysSet.size > 0 && !isKeyAvailable(e.key)) { 655 | e.preventDefault(); 656 | return; 657 | } 658 | 659 | // Prevent default behavior for backspace when at start 660 | if (e.key === 'Backspace' && hiddenInput.value.length === 0) { 661 | e.preventDefault(); 662 | } 663 | } 664 | 665 | function restart() { 666 | typedText = ''; 667 | for (let i = 0; i < charStates.length; i++) { 668 | charStates[i] = 'pending'; 669 | } 670 | if (hiddenInput) { 671 | hiddenInput.value = ''; 672 | } 673 | 674 | // Reset statistics 675 | startTime = null; 676 | totalErrors = 0; 677 | totalInputs = 0; 678 | 679 | // Clear real-time stats interval 680 | if (realtimeStatsInterval) { 681 | clearInterval(realtimeStatsInterval); 682 | realtimeStatsInterval = null; 683 | } 684 | 685 | // Update real-time stats display 686 | updateRealtimeStats(); 687 | 688 | // Clear keyboard highlights 689 | if (activeKeyElement) { 690 | activeKeyElement.classList.remove('active', 'active-error'); 691 | activeKeyElement = null; 692 | } 693 | if (activeKeyTimeout) { 694 | clearTimeout(activeKeyTimeout); 695 | activeKeyTimeout = null; 696 | } 697 | 698 | // Clear key tip if enabled 699 | if (currentTipKeyElement) { 700 | currentTipKeyElement.classList.remove('key-tip'); 701 | currentTipKeyElement = null; 702 | } 703 | if (currentTipShiftElement) { 704 | currentTipShiftElement.classList.remove('key-tip'); 705 | currentTipShiftElement = null; 706 | } 707 | 708 | // Show typing container and hide completion screen and stats dashboard 709 | const typingTextContainer = document.querySelector('.typing-text-container'); 710 | if (typingTextContainer) { 711 | typingTextContainer.style.display = 'block'; 712 | } 713 | if (completionScreen) { 714 | completionScreen.style.display = 'none'; 715 | } 716 | if (statsDashboard) { 717 | statsDashboard.style.display = 'none'; 718 | } 719 | 720 | // Show real-time stats again if configured 721 | if (realtimeStatsContainer) { 722 | updateRealtimeStats(); 723 | } 724 | 725 | // Show keyboard-stats-wrapper again 726 | const keyboardStatsWrapper = document.querySelector('.keyboard-stats-wrapper'); 727 | if (keyboardStatsWrapper) { 728 | keyboardStatsWrapper.style.display = 'block'; 729 | } 730 | 731 | // Show keyboard again if it was enabled 732 | if (keyboardContainer && keyboardEnabled) { 733 | keyboardContainer.classList.add('visible'); 734 | } 735 | 736 | // Show the restart button again 737 | if (restartButton && restartButton.parentElement) { 738 | restartButton.parentElement.style.display = 'block'; 739 | } 740 | 741 | renderText(); 742 | setStatus('Ready'); 743 | 744 | // Focus the input after a short delay 745 | setTimeout(() => { 746 | if (hiddenInput) { 747 | hiddenInput.focus(); 748 | } 749 | }, 50); 750 | } 751 | 752 | // Calculate real-time statistics (while typing) 753 | function calculateRealtimeStats() { 754 | // Calculate chars typed and total 755 | const charsTyped = typedText.length; 756 | const charsTotal = originalText.length; 757 | 758 | if (startTime === null) { 759 | return { 760 | speed: 0, 761 | accuracy: 0, 762 | time: 0, 763 | errors: 0, 764 | errorsLeft: 0, 765 | chars: { typed: charsTyped, total: charsTotal } 766 | }; 767 | } 768 | 769 | const currentTime = Date.now(); 770 | const totalTimeSeconds = (currentTime - startTime) / 1000; 771 | const totalTimeMinutes = totalTimeSeconds / 60; 772 | 773 | // Count errors left (unfixed incorrect characters) 774 | let errorsLeft = 0; 775 | for (let i = 0; i < charStates.length; i++) { 776 | if (charStates[i] === 'incorrect') { 777 | errorsLeft++; 778 | } 779 | } 780 | 781 | // Calculate accuracy: (correct inputs / total inputs) * 100 782 | const correctInputs = totalInputs - totalErrors; 783 | const accuracy = totalInputs > 0 ? (correctInputs / totalInputs) * 100 : 0; 784 | 785 | // Calculate words per minute 786 | // Count words by splitting on whitespace 787 | const wordsTyped = originalText.trim().split(/\s+/).filter(word => word.length > 0).length; 788 | const wpm = totalTimeMinutes > 0 ? wordsTyped / totalTimeMinutes : 0; 789 | 790 | return { 791 | speed: wpm, 792 | accuracy: accuracy, 793 | time: totalTimeSeconds, 794 | errors: totalErrors, 795 | errorsLeft: errorsLeft, 796 | chars: { typed: charsTyped, total: charsTotal } 797 | }; 798 | } 799 | 800 | // Update real-time stats display 801 | function updateRealtimeStats() { 802 | if (!realtimeStatsContainer) return; 803 | 804 | // Check if realTimeStats is configured and has items 805 | if (!config.realTimeStats || !Array.isArray(config.realTimeStats) || config.realTimeStats.length === 0) { 806 | realtimeStatsContainer.style.display = 'none'; 807 | // Clear interval if stats are disabled 808 | if (realtimeStatsInterval) { 809 | clearInterval(realtimeStatsInterval); 810 | realtimeStatsInterval = null; 811 | } 812 | return; 813 | } 814 | 815 | const stats = calculateRealtimeStats(); 816 | realtimeStatsContainer.style.display = 'flex'; 817 | 818 | // Clear existing content 819 | realtimeStatsContainer.innerHTML = ''; 820 | 821 | // Map of stat keys to display info 822 | const statMap = { 823 | speed: { label: 'WPM', value: stats.speed, format: (v) => v.toFixed(1) }, 824 | accuracy: { label: 'Accuracy', value: stats.accuracy, format: (v) => v.toFixed(1) + '%' }, 825 | time: { label: 'Time', value: stats.time, format: (v) => { 826 | if (v < 60) { 827 | return v.toFixed(1) + 's'; 828 | } else { 829 | const minutes = Math.floor(v / 60); 830 | const seconds = (v % 60).toFixed(1); 831 | return `${minutes}m ${seconds}s`; 832 | } 833 | }}, 834 | errors: { label: 'Errors', value: stats.errors, format: (v) => Math.round(v).toString() }, 835 | errorsLeft: { label: 'Errors Left', value: stats.errorsLeft, format: (v) => Math.round(v).toString() }, 836 | chars: { label: 'Chars', value: stats.chars, format: (v) => `${v.typed}/${v.total}` } 837 | }; 838 | 839 | // Create stat items for each configured stat 840 | config.realTimeStats.forEach(statKey => { 841 | const statInfo = statMap[statKey]; 842 | if (!statInfo) return; // Skip invalid stat keys 843 | 844 | const statItem = document.createElement('div'); 845 | statItem.className = 'realtime-stat-item'; 846 | 847 | const statLabel = document.createElement('span'); 848 | statLabel.className = 'realtime-stat-label'; 849 | statLabel.textContent = statInfo.label; 850 | 851 | const statValue = document.createElement('span'); 852 | statValue.className = 'realtime-stat-value'; 853 | statValue.textContent = statInfo.format(statInfo.value); 854 | 855 | statItem.appendChild(statLabel); 856 | statItem.appendChild(statValue); 857 | realtimeStatsContainer.appendChild(statItem); 858 | }); 859 | 860 | // Start periodic updates if typing has started and interval not already running 861 | if (startTime !== null && !realtimeStatsInterval) { 862 | realtimeStatsInterval = setInterval(() => { 863 | updateRealtimeStats(); 864 | }, 100); // Update every 100ms for smooth time updates 865 | } 866 | } 867 | 868 | function calculateStatistics() { 869 | console.log('Calculating statistics...'); 870 | console.log('startTime:', startTime, 'totalInputs:', totalInputs, 'totalErrors:', totalErrors); 871 | 872 | if (startTime === null) { 873 | console.log('No typing started, returning null'); 874 | return null; // No typing started 875 | } 876 | 877 | const endTime = Date.now(); 878 | const totalTimeSeconds = (endTime - startTime) / 1000; 879 | const totalTimeMinutes = totalTimeSeconds / 60; 880 | 881 | // Count errors left (unfixed incorrect characters) 882 | let errorsLeft = 0; 883 | for (let i = 0; i < charStates.length; i++) { 884 | if (charStates[i] === 'incorrect') { 885 | errorsLeft++; 886 | } 887 | } 888 | 889 | // Calculate accuracy: (correct inputs / total inputs) * 100 890 | const correctInputs = totalInputs - totalErrors; 891 | const accuracy = totalInputs > 0 ? (correctInputs / totalInputs) * 100 : 0; 892 | 893 | // Calculate words per minute 894 | // Count words by splitting on whitespace 895 | const wordsTyped = originalText.trim().split(/\s+/).filter(word => word.length > 0).length; 896 | const wpm = totalTimeMinutes > 0 ? wordsTyped / totalTimeMinutes : 0; 897 | 898 | const stats = { 899 | totalErrors: totalErrors, 900 | errorsLeft: errorsLeft, 901 | totalTime: totalTimeSeconds, 902 | accuracy: accuracy, 903 | speed: wpm 904 | }; 905 | 906 | console.log('Calculated statistics:', stats); 907 | return stats; 908 | } 909 | 910 | async function saveStatistics(stats) { 911 | console.log('saveStatistics called with:', stats); 912 | try { 913 | // Format statistics text 914 | const statsText = `Typing Statistics 915 | ================== 916 | 917 | Total Errors Made: ${stats.totalErrors} 918 | Errors Left (Unfixed): ${stats.errorsLeft} 919 | Total Time: ${stats.totalTime.toFixed(2)} seconds 920 | Accuracy: ${stats.accuracy.toFixed(2)}% 921 | Speed: ${stats.speed.toFixed(2)} words per minute 922 | 923 | Generated: ${new Date().toLocaleString()} 924 | `; 925 | 926 | console.log('Sending stats to server:', statsText); 927 | const response = await fetch('/save-stats', { 928 | method: 'POST', 929 | headers: { 930 | 'Content-Type': 'text/plain', 931 | }, 932 | body: statsText 933 | }); 934 | 935 | console.log('Server response status:', response.status); 936 | 937 | if (response.ok) { 938 | const result = await response.json(); 939 | console.log('Statistics saved to client/stats.txt', result); 940 | } else { 941 | const errorText = await response.text(); 942 | console.error('Failed to save statistics:', response.status, errorText); 943 | } 944 | } catch (error) { 945 | console.error('Error saving statistics:', error); 946 | } 947 | } 948 | 949 | // Parse stats from stats.txt file 950 | function parseStatsText(statsText) { 951 | const stats = {}; 952 | const lines = statsText.split('\n'); 953 | 954 | for (const line of lines) { 955 | if (line.includes('Total Errors Made:')) { 956 | const match = line.match(/Total Errors Made:\s*(\d+)/); 957 | if (match) stats.totalErrors = parseInt(match[1], 10); 958 | } else if (line.includes('Errors Left (Unfixed):')) { 959 | const match = line.match(/Errors Left \(Unfixed\):\s*(\d+)/); 960 | if (match) stats.errorsLeft = parseInt(match[1], 10); 961 | } else if (line.includes('Total Time:')) { 962 | const match = line.match(/Total Time:\s*([\d.]+)\s*seconds/); 963 | if (match) stats.totalTime = parseFloat(match[1]); 964 | } else if (line.includes('Accuracy:')) { 965 | const match = line.match(/Accuracy:\s*([\d.]+)%/); 966 | if (match) stats.accuracy = parseFloat(match[1]); 967 | } else if (line.includes('Speed:')) { 968 | const match = line.match(/Speed:\s*([\d.]+)\s*words per minute/); 969 | if (match) stats.speed = parseFloat(match[1]); 970 | } 971 | } 972 | 973 | return stats; 974 | } 975 | 976 | // Load and display stats dashboard 977 | async function showStatsDashboard() { 978 | // Hide typing container 979 | const typingTextContainer = document.querySelector('.typing-text-container'); 980 | if (typingTextContainer) { 981 | typingTextContainer.style.display = 'none'; 982 | } 983 | 984 | // Hide the restart button when dashboard is shown 985 | if (restartButton && restartButton.parentElement) { 986 | restartButton.parentElement.style.display = 'none'; 987 | } 988 | 989 | // Hide keyboard when dashboard is shown 990 | if (keyboardContainer) { 991 | keyboardContainer.classList.remove('visible'); 992 | } 993 | 994 | // Clear key tip when dashboard is shown 995 | if (currentTipKeyElement) { 996 | currentTipKeyElement.classList.remove('key-tip'); 997 | currentTipKeyElement = null; 998 | } 999 | if (currentTipShiftElement) { 1000 | currentTipShiftElement.classList.remove('key-tip'); 1001 | currentTipShiftElement = null; 1002 | } 1003 | 1004 | // Hide real-time stats when dashboard is shown 1005 | if (realtimeStatsContainer) { 1006 | realtimeStatsContainer.style.display = 'none'; 1007 | } 1008 | 1009 | // Hide keyboard-stats-wrapper when dashboard is shown 1010 | const keyboardStatsWrapper = document.querySelector('.keyboard-stats-wrapper'); 1011 | if (keyboardStatsWrapper) { 1012 | keyboardStatsWrapper.style.display = 'none'; 1013 | } 1014 | 1015 | // Hide completion screen if visible 1016 | if (completionScreen) { 1017 | completionScreen.style.display = 'none'; 1018 | } 1019 | 1020 | try { 1021 | const response = await fetch('./stats.txt'); 1022 | if (!response.ok) { 1023 | console.error('Failed to load stats file'); 1024 | // Fall back to simple completion screen 1025 | // Keyboard is already hidden above 1026 | if (completionScreen) { 1027 | completionScreen.style.display = 'flex'; 1028 | } 1029 | return; 1030 | } 1031 | 1032 | const statsText = await response.text(); 1033 | const stats = parseStatsText(statsText); 1034 | 1035 | // Update dashboard with stats 1036 | const speedEl = document.getElementById('stat-speed'); 1037 | const accuracyEl = document.getElementById('stat-accuracy'); 1038 | const timeEl = document.getElementById('stat-time'); 1039 | const errorsEl = document.getElementById('stat-errors'); 1040 | const errorsLeftEl = document.getElementById('stat-errors-left'); 1041 | 1042 | if (speedEl) speedEl.textContent = stats.speed ? stats.speed.toFixed(1) : '0'; 1043 | if (accuracyEl) accuracyEl.textContent = stats.accuracy ? stats.accuracy.toFixed(1) + '%' : '0%'; 1044 | if (timeEl) { 1045 | const timeValue = stats.totalTime || 0; 1046 | if (timeValue < 60) { 1047 | timeEl.textContent = timeValue.toFixed(1) + 's'; 1048 | } else { 1049 | const minutes = Math.floor(timeValue / 60); 1050 | const seconds = (timeValue % 60).toFixed(1); 1051 | timeEl.textContent = `${minutes}m ${seconds}s`; 1052 | } 1053 | } 1054 | if (errorsEl) errorsEl.textContent = stats.totalErrors || 0; 1055 | if (errorsLeftEl) errorsLeftEl.textContent = stats.errorsLeft || 0; 1056 | 1057 | // Show dashboard 1058 | if (statsDashboard) { 1059 | statsDashboard.style.display = 'flex'; 1060 | } 1061 | 1062 | if (hiddenInput) { 1063 | hiddenInput.blur(); 1064 | } 1065 | } catch (error) { 1066 | console.error('Error loading stats:', error); 1067 | // Fall back to simple completion screen 1068 | // Keyboard is already hidden above 1069 | if (completionScreen) { 1070 | completionScreen.style.display = 'flex'; 1071 | } 1072 | } 1073 | } 1074 | 1075 | function showCompletionScreen() { 1076 | console.log('showCompletionScreen called'); 1077 | 1078 | // Hide stats dashboard if visible 1079 | if (statsDashboard) { 1080 | statsDashboard.style.display = 'none'; 1081 | } 1082 | 1083 | if (!completionScreen) { 1084 | console.error('Completion screen element not found!'); 1085 | return; 1086 | } 1087 | 1088 | const typingTextContainer = document.querySelector('.typing-text-container'); 1089 | if (typingTextContainer) { 1090 | typingTextContainer.style.display = 'none'; 1091 | } 1092 | 1093 | // Hide keyboard when completion screen is shown 1094 | if (keyboardContainer) { 1095 | keyboardContainer.classList.remove('visible'); 1096 | } 1097 | 1098 | // Clear key tip when completion screen is shown 1099 | if (currentTipKeyElement) { 1100 | currentTipKeyElement.classList.remove('key-tip'); 1101 | currentTipKeyElement = null; 1102 | } 1103 | if (currentTipShiftElement) { 1104 | currentTipShiftElement.classList.remove('key-tip'); 1105 | currentTipShiftElement = null; 1106 | } 1107 | 1108 | // Hide real-time stats when completion screen is shown 1109 | if (realtimeStatsContainer) { 1110 | realtimeStatsContainer.style.display = 'none'; 1111 | } 1112 | 1113 | // Hide keyboard-stats-wrapper when completion screen is shown 1114 | const keyboardStatsWrapper = document.querySelector('.keyboard-stats-wrapper'); 1115 | if (keyboardStatsWrapper) { 1116 | keyboardStatsWrapper.style.display = 'none'; 1117 | } 1118 | 1119 | // Hide the restart button when completion screen is shown 1120 | if (restartButton && restartButton.parentElement) { 1121 | restartButton.parentElement.style.display = 'none'; 1122 | } 1123 | 1124 | // Calculate and save statistics 1125 | console.log('About to calculate statistics...'); 1126 | const stats = calculateStatistics(); 1127 | console.log('Statistics result:', stats); 1128 | 1129 | if (stats) { 1130 | console.log('Calling saveStatistics...'); 1131 | saveStatistics(stats).then(() => { 1132 | // After saving, check if we should show stats dashboard 1133 | if (config.showStats === true) { 1134 | // Wait a bit for the file to be written, then show dashboard 1135 | setTimeout(() => { 1136 | showStatsDashboard(); 1137 | }, 200); 1138 | } else { 1139 | // Show simple completion screen 1140 | // Ensure real-time stats are hidden 1141 | if (realtimeStatsContainer) { 1142 | realtimeStatsContainer.style.display = 'none'; 1143 | } 1144 | completionScreen.style.display = 'flex'; 1145 | if (hiddenInput) { 1146 | hiddenInput.blur(); 1147 | } 1148 | } 1149 | }); 1150 | } else { 1151 | console.log('No statistics to save (stats is null)'); 1152 | // Show simple completion screen 1153 | // Ensure real-time stats are hidden 1154 | if (realtimeStatsContainer) { 1155 | realtimeStatsContainer.style.display = 'none'; 1156 | } 1157 | completionScreen.style.display = 'flex'; 1158 | if (hiddenInput) { 1159 | hiddenInput.blur(); 1160 | } 1161 | } 1162 | } 1163 | 1164 | async function initialize() { 1165 | // Load config first 1166 | await loadConfig(); 1167 | 1168 | textContainer = document.getElementById('typing-text'); 1169 | hiddenInput = document.getElementById('hidden-input'); 1170 | completionScreen = document.getElementById('completion-screen'); 1171 | statsDashboard = document.getElementById('stats-dashboard'); 1172 | restartButton = document.getElementById('btn-restart'); 1173 | startOverButton = document.getElementById('btn-start-over'); 1174 | statsStartOverButton = document.getElementById('btn-stats-start-over'); 1175 | realtimeStatsContainer = document.getElementById('realtime-stats-container'); 1176 | 1177 | if (!textContainer || !hiddenInput) { 1178 | console.error('Required elements not found'); 1179 | return; 1180 | } 1181 | 1182 | // Initialize keyboard 1183 | initializeKeyboard(); 1184 | 1185 | // Set up event listeners 1186 | hiddenInput.addEventListener('input', handleInput); 1187 | hiddenInput.addEventListener('keydown', handleKeyDown); 1188 | 1189 | if (restartButton) { 1190 | restartButton.addEventListener('click', restart); 1191 | } 1192 | 1193 | if (startOverButton) { 1194 | startOverButton.addEventListener('click', restart); 1195 | } 1196 | 1197 | if (statsStartOverButton) { 1198 | statsStartOverButton.addEventListener('click', restart); 1199 | } 1200 | 1201 | // Focus the input when clicking on the text container 1202 | const typingTextContainer = document.querySelector('.typing-text-container'); 1203 | if (typingTextContainer) { 1204 | typingTextContainer.addEventListener('click', () => { 1205 | const isCompletionVisible = completionScreen && completionScreen.style.display === 'flex'; 1206 | const isStatsVisible = statsDashboard && statsDashboard.style.display === 'flex'; 1207 | if (hiddenInput && !isCompletionVisible && !isStatsVisible) { 1208 | hiddenInput.focus(); 1209 | } 1210 | }); 1211 | } 1212 | 1213 | // Load the text 1214 | loadText(); 1215 | 1216 | // Initialize real-time stats display 1217 | updateRealtimeStats(); 1218 | 1219 | // Focus the input after a short delay 1220 | setTimeout(() => { 1221 | const isCompletionVisible = completionScreen && completionScreen.style.display === 'flex'; 1222 | const isStatsVisible = statsDashboard && statsDashboard.style.display === 'flex'; 1223 | if (hiddenInput && !isCompletionVisible && !isStatsVisible) { 1224 | hiddenInput.focus(); 1225 | } 1226 | }, 100); 1227 | } 1228 | 1229 | // Initialize when DOM is ready 1230 | if (document.readyState === 'loading') { 1231 | document.addEventListener('DOMContentLoaded', initialize); 1232 | } else { 1233 | initialize(); 1234 | } 1235 | })(); 1236 | --------------------------------------------------------------------------------