├── .gitignore
├── client
├── text-to-input.txt
├── stats.txt
├── assets
│ ├── heart.svg
│ └── meteor.svg
├── config.json
├── example-app
│ ├── README.md
│ ├── example-app.css
│ ├── index.html
│ ├── help-content.html
│ └── example-app.js
├── app.js
├── help-content-template.html
├── help-content.html
├── help-modal.js
├── index.html
├── bespoke-template.css
├── typing-simulator.css
└── typing-simulator.js
├── .gitmodules
├── vite.config.js
├── package.json
├── .github
└── workflows
│ └── build-release.yml
├── extract_solution.py
├── LICENSE
├── AGENTS.md
├── README.md
├── server.js
└── BESPOKE-TEMPLATE.md
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist/
3 | .DS_Store
4 |
--------------------------------------------------------------------------------
/client/text-to-input.txt:
--------------------------------------------------------------------------------
1 | Another text to enter. Enter this text fast to beat other racers!
2 |
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "client/design-system"]
2 | path = client/design-system
3 | url = https://github.com/CodeSignal/learn_bespoke-design-system.git
4 |
--------------------------------------------------------------------------------
/client/stats.txt:
--------------------------------------------------------------------------------
1 | Score: 0
2 |
3 | Typing Statistics
4 | ==================
5 |
6 | Total Errors Made: 0
7 | Errors Left (Unfixed): 0
8 | Total Time: 10.30 seconds
9 | Accuracy: 100.00%
10 | Speed: 0.00 words per minute
11 |
12 | Generated: 04/12/2025, 16:28:18
13 |
--------------------------------------------------------------------------------
/client/assets/heart.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/client/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "gameType": "meteoriteRain",
3 | "keyboard": true,
4 | "availableKeys": [],
5 | "showStats": true,
6 | "realTimeStats": ["time", "chars"],
7 | "racing": {
8 | "opponentSpeeds": [0.3, 0.4, 0.5],
9 | "mistakesAllowed": 0
10 | },
11 | "meteoriteRain": {
12 | "meteoriteSpeed": 50,
13 | "spawnInterval": 2000,
14 | "pointsPerChar": 100,
15 | "difficulty": {
16 | "baseSpawnInterval": 2000,
17 | "minSpawnInterval": 1500,
18 | "baseSpeed": 50,
19 | "maxSpeed": 100,
20 | "difficultyIncreaseRate": 0.05
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 |
3 | export default defineConfig({
4 | root: './client',
5 | server: {
6 | host: '0.0.0.0',
7 | hmr: true,
8 | allowedHosts: true,
9 | port: 3000,
10 | proxy: {
11 | '/message': {
12 | target: 'http://localhost:3001',
13 | changeOrigin: true
14 | },
15 | '/save-stats': {
16 | target: 'http://localhost:3001',
17 | changeOrigin: true
18 | },
19 | '/ws': {
20 | target: 'ws://localhost:3001',
21 | ws: true,
22 | changeOrigin: true
23 | }
24 | }
25 | },
26 | build: {
27 | outDir: '../dist',
28 | emptyOutDir: true
29 | }
30 | });
31 |
--------------------------------------------------------------------------------
/client/example-app/README.md:
--------------------------------------------------------------------------------
1 | # Example App
2 |
3 | This directory contains an example application that demonstrates how to use the Bespoke Simulation template and its design system components. The example app showcases a simple interactive counter application that uses buttons, inputs, dropdowns, tags, and other design system components to illustrate the template's features and usage patterns. Accessible via the development server at `http://localhost:3000/example-app/index.html`.
4 |
5 | **Important:** This example app is included for reference and testing purposes only. When customizing this template for your own application, you should remove this entire `example-app` directory and replace it with your own application code:
6 |
7 | ```bash
8 | rm -rf client/example-app
9 | ```
10 |
11 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bespoke-template",
3 | "version": "0.0.2",
4 | "description": "Bespoke template with local development server and WebSocket messaging",
5 | "main": "server.js",
6 | "scripts": {
7 | "start": "npm run start:prod",
8 | "start:prod": "IS_PRODUCTION=true node server.js",
9 | "start:dev": "concurrently \"npm run dev:vite\" \"npm run dev:api\"",
10 | "dev:vite": "vite",
11 | "dev:api": "PORT=3001 node server.js",
12 | "build": "vite build"
13 | },
14 | "keywords": [
15 | "bespoke",
16 | "template",
17 | "development",
18 | "server",
19 | "websocket"
20 | ],
21 | "author": "",
22 | "license": "MIT",
23 | "dependencies": {
24 | "ws": "^8.14.2"
25 | },
26 | "devDependencies": {
27 | "concurrently": "^8.2.2",
28 | "vite": "^7.2.4"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/client/assets/meteor.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/.github/workflows/build-release.yml:
--------------------------------------------------------------------------------
1 | name: Build and Release
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 |
7 | permissions:
8 | contents: write
9 |
10 | jobs:
11 | build-and-release:
12 | runs-on: ubuntu-latest
13 |
14 | steps:
15 | - name: Checkout repository
16 | uses: actions/checkout@v4
17 |
18 | - name: Setup Node.js
19 | uses: actions/setup-node@v4
20 | with:
21 | node-version: '22.13.1'
22 | cache: 'npm'
23 |
24 | - name: Install all dependencies
25 | run: npm ci
26 |
27 | - name: Build project
28 | run: npm run build
29 |
30 | - name: Install production dependencies only
31 | run: |
32 | npm ci --production
33 |
34 | - name: Create release tarball
35 | run: |
36 | tar -czf release.tar.gz dist/ package.json server.js node_modules/
37 |
38 | - name: Upload build artifact (for workflow logs)
39 | uses: actions/upload-artifact@v4
40 | with:
41 | name: dist
42 | path: dist
43 |
44 | - name: Create GitHub Release and upload asset
45 | uses: ncipollo/release-action@v1
46 | with:
47 | token: ${{ secrets.GITHUB_TOKEN }}
48 | tag: v${{ github.run_number }}
49 | name: Release ${{ github.run_number }}
50 | body: |
51 | Latest build from main branch.
52 | artifacts: release.tar.gz
53 | allowUpdates: false
54 | draft: false
55 | prerelease: false
56 | makeLatest: true
57 |
--------------------------------------------------------------------------------
/client/app.js:
--------------------------------------------------------------------------------
1 | // app.js
2 | (function() {
3 | let websocket = null;
4 |
5 | function setStatus(msg) {
6 | // Status element removed - function kept for compatibility but does nothing
7 | }
8 |
9 | // Initialize WebSocket connection
10 | function initializeWebSocket() {
11 | const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
12 | const host = window.location.host;
13 | const wsUrl = `${protocol}//${host}/ws`;
14 |
15 | try {
16 | websocket = new WebSocket(wsUrl);
17 |
18 | websocket.onopen = function(event) {
19 | console.log('WebSocket connected');
20 | setStatus('Ready (WebSocket connected)');
21 | };
22 |
23 | websocket.onmessage = function(event) {
24 | try {
25 | const data = JSON.parse(event.data);
26 | if (data.type === 'message' && data.message) {
27 | alert(data.message);
28 | }
29 | } catch (error) {
30 | console.error('Error parsing WebSocket message:', error);
31 | }
32 | };
33 |
34 | websocket.onclose = function(event) {
35 | console.log('WebSocket disconnected');
36 | setStatus('Ready (WebSocket disconnected)');
37 |
38 | // Attempt to reconnect after 3 seconds
39 | setTimeout(() => {
40 | console.log('Attempting to reconnect WebSocket...');
41 | initializeWebSocket();
42 | }, 3000);
43 | };
44 |
45 | websocket.onerror = function(error) {
46 | console.error('WebSocket error:', error);
47 | setStatus('Ready (WebSocket error)');
48 | };
49 |
50 | } catch (error) {
51 | console.error('Failed to create WebSocket connection:', error);
52 | setStatus('Ready (WebSocket unavailable)');
53 | }
54 | }
55 |
56 | // Load help content and initialize modal
57 | async function initializeHelpModal() {
58 | try {
59 | const response = await fetch('./help-content.html');
60 | const helpContent = await response.text();
61 |
62 | // Initialize help modal with actual content
63 | HelpModal.init({
64 | triggerSelector: '#btn-help',
65 | content: helpContent,
66 | theme: 'auto'
67 | });
68 |
69 | setStatus('Ready');
70 | } catch (error) {
71 | console.error('Failed to load help content:', error);
72 | // Fallback to placeholder content
73 | HelpModal.init({
74 | triggerSelector: '#btn-help',
75 | content: '
Help content could not be loaded. Please check that help-content.html exists.
',
76 | theme: 'auto'
77 | });
78 | setStatus('Ready (help content unavailable)');
79 | }
80 | }
81 |
82 | // Initialize both help modal and WebSocket when DOM is ready
83 | function initialize() {
84 | initializeHelpModal();
85 | initializeWebSocket();
86 | }
87 |
88 | if (document.readyState === 'loading') {
89 | document.addEventListener('DOMContentLoaded', initialize);
90 | } else {
91 | initialize();
92 | }
93 | })();
94 |
--------------------------------------------------------------------------------
/client/example-app/example-app.css:
--------------------------------------------------------------------------------
1 | /* Example App Styles - Interactive Component Showcase */
2 |
3 | .bespoke .sidebar {
4 | padding: var(--UI-Spacing-spacing-xl);
5 | overflow-y: auto;
6 | }
7 |
8 | .bespoke .sidebar-section {
9 | display: flex;
10 | flex-direction: column;
11 | gap: var(--UI-Spacing-spacing-xl);
12 | }
13 |
14 | .bespoke .sidebar-section h2 {
15 | font-size: var(--Fonts-Headlines-sm);
16 | font-weight: 600;
17 | color: var(--Colors-Text-Body-Strongest);
18 | margin: 0 0 var(--UI-Spacing-spacing-ml) 0;
19 | }
20 |
21 | .bespoke .control-group {
22 | display: flex;
23 | flex-direction: column;
24 | gap: var(--UI-Spacing-spacing-s);
25 | }
26 |
27 | .bespoke .control-group label {
28 | font-size: var(--Fonts-Body-Default-sm);
29 | font-weight: 500;
30 | color: var(--Colors-Text-Body-Strong);
31 | }
32 |
33 | .bespoke .control-group .button {
34 | width: 100%;
35 | }
36 |
37 | .bespoke .control-group-buttons {
38 | flex-direction: row;
39 | gap: var(--UI-Spacing-spacing-s);
40 | }
41 |
42 | .bespoke .control-group-buttons .button {
43 | flex: 1;
44 | }
45 |
46 | .bespoke .content-area {
47 | overflow-y: auto;
48 | padding: var(--UI-Spacing-spacing-xl);
49 | }
50 |
51 | .bespoke .display-container {
52 | max-width: 800px;
53 | margin: 0 auto;
54 | display: flex;
55 | flex-direction: column;
56 | gap: var(--UI-Spacing-spacing-xl);
57 | }
58 |
59 | .bespoke .counter-display {
60 | flex-direction: column;
61 | align-items: center;
62 | justify-content: center;
63 | padding: var(--UI-Spacing-spacing-2xl);
64 | text-align: center;
65 | }
66 |
67 | .bespoke .counter-display h2 {
68 | font-size: var(--Fonts-Headlines-sm);
69 | font-weight: 600;
70 | color: var(--Colors-Text-Body-Strong);
71 | margin: 0 0 var(--UI-Spacing-spacing-ml) 0;
72 | text-align: center;
73 | }
74 |
75 | .bespoke .counter-value {
76 | font-size: 4rem;
77 | font-weight: 700;
78 | font-family: var(--heading-family);
79 | color: var(--Colors-Text-Body-Strongest);
80 | line-height: 1;
81 | text-align: center;
82 | }
83 |
84 | .bespoke .tags-container {
85 | display: flex;
86 | gap: var(--UI-Spacing-spacing-ml);
87 | justify-content: center;
88 | flex-wrap: wrap;
89 | }
90 |
91 | .bespoke .settings-display {
92 | flex-direction: column;
93 | align-items: flex-start;
94 | justify-content: flex-start;
95 | padding: var(--UI-Spacing-spacing-xl);
96 | }
97 |
98 | .bespoke .settings-display h3 {
99 | font-size: var(--Fonts-Headlines-xs);
100 | font-weight: 600;
101 | color: var(--Colors-Text-Body-Strong);
102 | margin: 0 0 var(--UI-Spacing-spacing-ml) 0;
103 | width: 100%;
104 | }
105 |
106 | .bespoke .setting-item {
107 | font-size: var(--Fonts-Body-Default-sm);
108 | color: var(--Colors-Text-Body-Default);
109 | margin-bottom: var(--UI-Spacing-spacing-s);
110 | width: 100%;
111 | }
112 |
113 | .bespoke .setting-item:last-child {
114 | margin-bottom: 0;
115 | }
116 |
117 | .bespoke .setting-item strong {
118 | color: var(--Colors-Text-Body-Strong);
119 | margin-right: var(--UI-Spacing-spacing-xs);
120 | }
121 |
--------------------------------------------------------------------------------
/extract_solution.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | """
3 | Extract and print typing statistics from stats.txt file.
4 | """
5 |
6 | import os
7 | import re
8 | from pathlib import Path
9 |
10 |
11 | def extract_stats():
12 | """Read stats.txt and print all statistics to console."""
13 | # Get the project root directory (where this script is located)
14 | script_dir = Path(__file__).parent
15 | stats_file = script_dir / 'client' / 'stats.txt'
16 |
17 | # Check if stats file exists
18 | if not stats_file.exists():
19 | print(f"Error: stats.txt not found at {stats_file}")
20 | return
21 |
22 | # Read the stats file
23 | try:
24 | with open(stats_file, 'r', encoding='utf-8') as f:
25 | content = f.read()
26 | except Exception as e:
27 | print(f"Error reading stats.txt: {e}")
28 | return
29 |
30 | # Parse and extract statistics
31 | stats = {}
32 |
33 | # Extract Total Errors Made
34 | match = re.search(r'Total Errors Made:\s*(\d+)', content)
35 | if match:
36 | stats['total_errors'] = int(match.group(1))
37 |
38 | # Extract Errors Left (Unfixed)
39 | match = re.search(r'Errors Left \(Unfixed\):\s*(\d+)', content)
40 | if match:
41 | stats['errors_left'] = int(match.group(1))
42 |
43 | # Extract Total Time
44 | match = re.search(r'Total Time:\s*([\d.]+)\s*seconds', content)
45 | if match:
46 | stats['total_time'] = float(match.group(1))
47 |
48 | # Extract Accuracy
49 | match = re.search(r'Accuracy:\s*([\d.]+)%', content)
50 | if match:
51 | stats['accuracy'] = float(match.group(1))
52 |
53 | # Extract Speed (WPM)
54 | match = re.search(r'Speed:\s*([\d.]+)\s*words per minute', content)
55 | if match:
56 | stats['speed'] = float(match.group(1))
57 |
58 | # Extract Generated timestamp
59 | match = re.search(r'Generated:\s*(.+)', content)
60 | if match:
61 | stats['generated'] = match.group(1).strip()
62 |
63 | # Extract Status (win/lose) for racing games
64 | match = re.search(r'Status:\s*(win|lose)', content, re.IGNORECASE)
65 | if match:
66 | stats['status'] = match.group(1).lower()
67 |
68 | # Extract Score for meteorite rain games
69 | match = re.search(r'Score:\s*(\d+)', content, re.IGNORECASE)
70 | if match:
71 | stats['score'] = int(match.group(1))
72 |
73 | # Print all statistics
74 | # Print status first if available (for racing games)
75 | if 'status' in stats:
76 | status_display = "WIN" if stats['status'] == 'win' else "LOSE"
77 | print(f"Status: {status_display}")
78 | print("=" * 50)
79 | print()
80 |
81 | # Print score if available (for meteorite rain games)
82 | if 'score' in stats:
83 | print(f"Score: {stats['score']}")
84 | print("=" * 50)
85 | print()
86 |
87 | print("Typing Statistics")
88 | print("=" * 50)
89 | print()
90 |
91 | if 'total_errors' in stats:
92 | print(f"Total Errors Made: {stats['total_errors']}")
93 |
94 | if 'errors_left' in stats:
95 | print(f"Errors Left (Unfixed): {stats['errors_left']}")
96 |
97 | if 'total_time' in stats:
98 | time_value = stats['total_time']
99 | if time_value < 60:
100 | print(f"Total Time: {time_value:.2f} seconds")
101 | else:
102 | minutes = int(time_value // 60)
103 | seconds = time_value % 60
104 | print(f"Total Time: {minutes}m {seconds:.2f}s")
105 |
106 | if 'accuracy' in stats:
107 | print(f"Accuracy: {stats['accuracy']:.2f}%")
108 |
109 | if 'speed' in stats:
110 | print(f"Speed: {stats['speed']:.2f} words per minute")
111 |
112 | if 'generated' in stats:
113 | print(f"Generated: {stats['generated']}")
114 |
115 | print()
116 |
117 |
118 | if __name__ == '__main__':
119 | extract_stats()
120 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Elastic License 2.0
2 |
3 | URL: https://www.elastic.co/licensing/elastic-license
4 |
5 | ## Acceptance
6 |
7 | By using the software, you agree to all of the terms and conditions below.
8 |
9 | ## Copyright License
10 |
11 | The licensor grants you a non-exclusive, royalty-free, worldwide,
12 | non-sublicensable, non-transferable license to use, copy, distribute, make
13 | available, and prepare derivative works of the software, in each case subject to
14 | the limitations and conditions below.
15 |
16 | ## Limitations
17 |
18 | You may not provide the software to third parties as a hosted or managed
19 | service, where the service provides users with access to any substantial set of
20 | the features or functionality of the software.
21 |
22 | You may not move, change, disable, or circumvent the license key functionality
23 | in the software, and you may not remove or obscure any functionality in the
24 | software that is protected by the license key.
25 |
26 | You may not alter, remove, or obscure any licensing, copyright, or other notices
27 | of the licensor in the software. Any use of the licensor’s trademarks is subject
28 | to applicable law.
29 |
30 | ## Patents
31 |
32 | The licensor grants you a license, under any patent claims the licensor can
33 | license, or becomes able to license, to make, have made, use, sell, offer for
34 | sale, import and have imported the software, in each case subject to the
35 | limitations and conditions in this license. This license does not cover any
36 | patent claims that you cause to be infringed by modifications or additions to
37 | the software. If you or your company make any written claim that the software
38 | infringes or contributes to infringement of any patent, your patent license for
39 | the software granted under these terms ends immediately. If your company makes
40 | such a claim, your patent license ends immediately for work on behalf of your
41 | company.
42 |
43 | ## Notices
44 |
45 | You must ensure that anyone who gets a copy of any part of the software from you
46 | also gets a copy of these terms.
47 |
48 | If you modify the software, you must include in any modified copies of the
49 | software prominent notices stating that you have modified the software.
50 |
51 | ## No Other Rights
52 |
53 | These terms do not imply any licenses other than those expressly granted in
54 | these terms.
55 |
56 | ## Termination
57 |
58 | If you use the software in violation of these terms, such use is not licensed,
59 | and your licenses will automatically terminate. If the licensor provides you
60 | with a notice of your violation, and you cease all violation of this license no
61 | later than 30 days after you receive that notice, your licenses will be
62 | reinstated retroactively. However, if you violate these terms after such
63 | reinstatement, any additional violation of these terms will cause your licenses
64 | to terminate automatically and permanently.
65 |
66 | ## No Liability
67 |
68 | *As far as the law allows, the software comes as is, without any warranty or
69 | condition, and the licensor will not be liable to you for any damages arising
70 | out of these terms or the use or nature of the software, under any kind of
71 | legal claim.*
72 |
73 | ## Definitions
74 |
75 | The **licensor** is the entity offering these terms, and the **software** is the
76 | software the licensor makes available under these terms, including any portion
77 | of it.
78 |
79 | **you** refers to the individual or entity agreeing to these terms.
80 |
81 | **your company** is any legal entity, sole proprietorship, or other kind of
82 | organization that you work for, plus all organizations that have control over,
83 | are under the control of, or are under common control with that
84 | organization. **control** means ownership of substantially all the assets of an
85 | entity, or the power to direct its management and policies by vote, contract, or
86 | otherwise. Control can be direct or indirect.
87 |
88 | **your licenses** are all the licenses granted to you for the software under
89 | these terms.
90 |
91 | **use** means anything you do with the software requiring one of your licenses.
92 |
93 | **trademark** means trademarks, service marks, and similar rights.
94 |
--------------------------------------------------------------------------------
/AGENTS.md:
--------------------------------------------------------------------------------
1 | # Repository Guidelines
2 |
3 | This repository contains a template for building embedded applications using
4 | the Bespoke Simulation framework. For complete template documentation, see
5 | [BESPOKE-TEMPLATE.md](./BESPOKE-TEMPLATE.md).
6 |
7 | ## Overview
8 |
9 | This template provides:
10 | - CodeSignal Design System integration
11 | - Consistent layout components (header, sidebar, main content area)
12 | - Help modal system
13 | - Local development server with WebSocket support
14 | - Standardized file structure and naming conventions
15 |
16 | ## Quick Start
17 |
18 | 1. **Customize the HTML template** (`client/index.html`):
19 | - Replace `` with your page title
20 | - Replace `` with your app name
21 | - Add your main content at ``
22 | - Add app-specific CSS links at ``
23 | - Add app-specific JavaScript at ``
24 |
25 | 2. **Create your application files**:
26 | - App-specific CSS (e.g., `my-app.css`)
27 | - App-specific JavaScript (e.g., `my-app.js`)
28 | - Help content (based on `help-content-template.html`)
29 |
30 | 3. **Start the development server**:
31 | ```bash
32 | npm start
33 | ```
34 | Server runs on `http://localhost:3000`
35 |
36 | ## Key Conventions
37 |
38 | ### Status Messages
39 |
40 | Use these exact status messages for consistency:
41 |
42 | - "Ready" - Application loaded successfully
43 | - "Loading..." - Data is being loaded
44 | - "Saving..." - Data is being saved
45 | - "Changes saved" - Auto-save completed successfully
46 | - "Save failed (will retry)" - Server save failed, will retry
47 | - "Failed to load data" - Data loading failed
48 | - "Auto-save initialized" - Auto-save system started
49 |
50 | ### File Naming
51 |
52 | - CSS files: kebab-case (e.g., `my-app.css`)
53 | - JavaScript files: kebab-case (e.g., `my-app.js`)
54 | - Data files: kebab-case (e.g., `solution.json`)
55 | - Image files: kebab-case (e.g., `overview.png`)
56 |
57 | ### Error Handling
58 |
59 | - Wrap all async operations in try-catch blocks
60 | - Provide meaningful error messages to users
61 | - Log errors to console for debugging
62 | - Implement retry logic for network operations
63 | - Handle localStorage quota exceeded errors
64 | - Validate data before saving operations
65 |
66 | ## Development Workflow
67 |
68 | ### Build and Test
69 |
70 | ```bash
71 | # Start development server
72 | npm start
73 |
74 | # Development mode (same as start)
75 | npm run dev
76 | ```
77 |
78 | ### WebSocket Messaging
79 |
80 | The server provides a `POST /message` endpoint for real-time messaging:
81 |
82 | ```bash
83 | curl -X POST http://localhost:3000/message \
84 | -H "Content-Type: application/json" \
85 | -d '{"message": "Your message here"}'
86 | ```
87 |
88 | This sends alerts to connected clients. Requires `ws` package:
89 | ```bash
90 | npm install
91 | ```
92 |
93 | ## Template Documentation
94 |
95 | For detailed information about:
96 | - Design System usage and components
97 | - CSS implementation guidelines
98 | - JavaScript API (HelpModal, status management)
99 | - Component reference and examples
100 | - Customization options
101 |
102 | See [BESPOKE-TEMPLATE.md](./BESPOKE-TEMPLATE.md).
103 |
104 | ## Project Structure
105 |
106 | ```
107 | client/
108 | ├── index.html # Main HTML template
109 | ├── app.js # Application logic
110 | ├── bespoke-template.css # Template-specific styles
111 | ├── help-modal.js # Help modal system
112 | ├── help-content-template.html # Help content template
113 | └── design-system/ # CodeSignal Design System
114 | ├── colors/
115 | ├── spacing/
116 | ├── typography/
117 | └── components/
118 | server.js # Development server
119 | ```
120 |
121 | ## Notes for AI Agents
122 |
123 | When working on applications built with this template:
124 |
125 | 1. **Always reference BESPOKE-TEMPLATE.md** for template-specific
126 | implementation details
127 | 2. **Follow the conventions** listed above for status messages and file naming
128 | 3. **Use Design System components** directly - see BESPOKE-TEMPLATE.md for
129 | component classes and usage
130 | 4. **Maintain consistency** with the template's structure and patterns
131 | 5. **Keep guidelines up to date** by editing this AGENTS.md file as the codebase evolves
--------------------------------------------------------------------------------
/client/example-app/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Design System Component Showcase
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
36 |
37 |
38 |
39 |
40 |
64 |
65 |
66 |
67 |
68 |
69 |
73 |
74 |
75 |
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 |
9 | Contents
10 |
18 |
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 | First step...
34 | Second step...
35 | Third step...
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 | Step 1: Description of first workflow step
64 | Step 2: Description of second workflow step
65 | Step 3: Description of third workflow step
66 | Step 4: Description of fourth workflow step
67 |
68 |
69 | Tips & Best Practices
70 |
71 | Tip 1: Description of best practice
72 | Tip 2: Description of best practice
73 | Tip 3: Description of best practice
74 |
75 |
76 |
77 |
78 |
79 | Shortcuts
80 |
81 | ESC - Close help modal
82 | Keyboard shortcut 1 - Description of what it does
83 | Keyboard shortcut 2 - Description of what it does
84 | Mouse action 1 - Description of what it does
85 | Mouse action 2 - Description of what it does
86 |
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 |
5 | Contents
6 |
12 |
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 |
20 | How to use Design System components in an interactive application
21 | Component integration and state management
22 | Real-time updates between input controls and visual display
23 | Component variants: buttons (primary, secondary, danger), boxes/cards, tags (success, error), dropdowns, and inputs
24 |
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 |
38 | 1 - Increment/decrement by 1
39 | 5 - Increment/decrement by 5
40 | 10 - Increment/decrement by 10
41 | 25 - Increment/decrement by 25
42 |
43 | The selected increment amount is displayed in the settings panel below the counter.
44 |
45 | Action Buttons
46 |
47 | Increment (Primary button) - Increases the counter by the selected increment amount
48 | Decrement (Secondary button) - Decreases the counter by the selected increment amount
49 | Reset (Danger button) - Resets the counter to 0
50 |
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 |
64 | Active (Primary tag) - Shown when counter is at 0
65 | Positive (Success tag) - Shown when counter is greater than 0
66 | Negative (Error tag) - Shown when counter is less than 0
67 |
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 |
83 | Primary Button - Used for the main increment action
84 | Secondary Button - Used for the decrement action
85 | Danger Button - Used for the reset action
86 |
87 |
88 | Boxes/Cards
89 |
90 | Card Box - Used for the counter display and settings panel
91 |
92 |
93 | Tags
94 |
95 | Primary Tag - Shows "Active" status
96 | Success Tag - Shows "Positive" status
97 | Error Tag - Shows "Negative" status
98 |
99 |
100 | Dropdown
101 |
102 | Dropdown Component - Used to select the increment amount
103 |
104 |
105 | Input
106 |
107 | Text Input - Used for the counter label input
108 |
109 |
110 |
--------------------------------------------------------------------------------
/client/help-content.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Contents
6 |
14 |
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 | The text to type will be displayed in grey
28 | Click on the text area or start typing to begin
29 | Type each character as it appears in the text
30 | Watch as correctly typed characters turn black and mistakes turn red
31 | Use backspace to fix errors if needed
32 | When you finish, you'll see a completion screen
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 | Start Typing: Click on the text area or simply start typing. The text will be loaded automatically.
59 | Type Carefully: Type each character as it appears. Focus on accuracy first, then speed.
60 | Handle Mistakes: If you make a mistake, it will be highlighted in red. You can continue typing or use backspace to fix it.
61 | Complete the Text: Keep typing until you've completed the entire text.
62 | Review and Restart: After completion, you can start over to practice again.
63 |
64 |
65 | Tips & Best Practices
66 |
67 | Focus on accuracy over speed - it's better to type correctly than quickly
68 | Use proper typing posture and finger placement
69 | Don't look at your keyboard - keep your eyes on the screen
70 | Practice regularly to improve your muscle memory
71 | Fix mistakes immediately to build good habits
72 |
73 |
74 |
75 |
76 |
77 | Shortcuts
78 |
79 | ESC - Close help modal
80 | Backspace/Delete - Go back and fix mistakes
81 | Click on text area - Focus the input to start typing
82 | Restart button - Start over from the beginning
83 |
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 |
48 |
52 |
53 | ${this.options.content}
54 |
55 |
56 | `;
57 |
58 | // Initially hidden
59 | this.modal.style.display = 'none';
60 | document.body.appendChild(this.modal);
61 | }
62 |
63 | bindEvents() {
64 | // Find trigger element
65 | this.trigger = document.querySelector(this.options.triggerSelector);
66 | if (!this.trigger) {
67 | console.warn(`HelpModal: Trigger element '${this.options.triggerSelector}' not found`);
68 | return;
69 | }
70 |
71 | // Convert link to button if needed
72 | if (this.trigger.tagName === 'A') {
73 | this.trigger.addEventListener('click', (e) => {
74 | e.preventDefault();
75 | this.open();
76 | });
77 | } else {
78 | this.trigger.addEventListener('click', () => this.open());
79 | }
80 |
81 | // Close button
82 | const closeBtn = this.modal.querySelector('.modal-close');
83 | closeBtn.addEventListener('click', () => this.close());
84 |
85 | // Backdrop click
86 | const backdrop = this.modal.querySelector('.modal-backdrop');
87 | backdrop.addEventListener('click', () => this.close());
88 |
89 | // ESC key
90 | document.addEventListener('keydown', (e) => {
91 | if (e.key === 'Escape' && this.isOpen) {
92 | this.close();
93 | }
94 | });
95 |
96 | // Handle internal navigation links
97 | this.modal.addEventListener('click', (e) => {
98 | if (e.target.matches('a[href^="#"]')) {
99 | e.preventDefault();
100 | const targetId = e.target.getAttribute('href').substring(1);
101 | const targetElement = this.modal.querySelector(`#${targetId}`);
102 | if (targetElement) {
103 | targetElement.scrollIntoView({ behavior: 'smooth' });
104 | }
105 | }
106 | });
107 | }
108 |
109 | open() {
110 | if (this.isOpen) return;
111 |
112 | this.isOpen = true;
113 | this.modal.style.display = 'flex'; // Use flex to center the modal
114 | document.body.style.overflow = 'hidden'; // Prevent background scrolling
115 |
116 | // Focus management
117 | const closeBtn = this.modal.querySelector('.modal-close');
118 | closeBtn.focus();
119 |
120 | // Trigger custom event
121 | this.trigger.dispatchEvent(new CustomEvent('helpModal:open', { detail: this }));
122 | }
123 |
124 | close() {
125 | if (!this.isOpen) return;
126 |
127 | this.isOpen = false;
128 | this.modal.style.display = 'none';
129 | document.body.style.overflow = ''; // Restore scrolling
130 |
131 | // Return focus to trigger
132 | this.trigger.focus();
133 |
134 | // Trigger custom event
135 | this.trigger.dispatchEvent(new CustomEvent('helpModal:close', { detail: this }));
136 | }
137 |
138 | // Public API methods
139 | static init(options) {
140 | return new HelpModal(options);
141 | }
142 |
143 | destroy() {
144 | if (this.modal && this.modal.parentNode) {
145 | this.modal.parentNode.removeChild(this.modal);
146 | }
147 | document.body.style.overflow = '';
148 | }
149 |
150 | // Method to update content dynamically
151 | updateContent(newContent) {
152 | const modalBody = this.modal.querySelector('.modal-body');
153 | if (modalBody) {
154 | modalBody.innerHTML = newContent;
155 | }
156 | }
157 | }
158 |
159 | // Export for use
160 | if (typeof module !== 'undefined' && module.exports) {
161 | module.exports = HelpModal;
162 | } else {
163 | window.HelpModal = HelpModal;
164 | }
165 |
--------------------------------------------------------------------------------
/client/example-app/example-app.js:
--------------------------------------------------------------------------------
1 | // example-app.js
2 | // Interactive Component Showcase Application
3 |
4 | (function() {
5 | const status = document.getElementById('status');
6 |
7 | // App state
8 | let counterValue = 0;
9 | let incrementAmount = 1;
10 | let counterLabel = 'Counter';
11 | let dropdownInstance = null;
12 |
13 | function setStatus(msg) {
14 | if (status) {
15 | status.textContent = msg;
16 | }
17 | }
18 |
19 | // Update counter display
20 | function updateCounterDisplay() {
21 | const counterDisplay = document.getElementById('counter-value');
22 | const labelDisplay = document.getElementById('display-label');
23 | const labelValueDisplay = document.getElementById('display-label-value');
24 | const incrementValueDisplay = document.getElementById('display-increment-value');
25 |
26 | if (counterDisplay) {
27 | counterDisplay.textContent = counterValue;
28 | }
29 |
30 | if (labelDisplay) {
31 | labelDisplay.textContent = counterLabel;
32 | }
33 |
34 | if (labelValueDisplay) {
35 | labelValueDisplay.textContent = counterLabel;
36 | }
37 |
38 | if (incrementValueDisplay) {
39 | incrementValueDisplay.textContent = incrementAmount;
40 | }
41 |
42 | // Update status tags
43 | updateStatusTags();
44 | }
45 |
46 | // Update status tags based on counter value
47 | function updateStatusTags() {
48 | const primaryTag = document.getElementById('status-tag-primary');
49 | const positiveTag = document.getElementById('status-tag-positive');
50 | const negativeTag = document.getElementById('status-tag-negative');
51 |
52 | if (counterValue > 0) {
53 | if (primaryTag) primaryTag.style.display = 'none';
54 | if (positiveTag) positiveTag.style.display = 'inline-block';
55 | if (negativeTag) negativeTag.style.display = 'none';
56 | } else if (counterValue < 0) {
57 | if (primaryTag) primaryTag.style.display = 'none';
58 | if (positiveTag) positiveTag.style.display = 'none';
59 | if (negativeTag) negativeTag.style.display = 'inline-block';
60 | } else {
61 | if (primaryTag) primaryTag.style.display = 'inline-block';
62 | if (positiveTag) positiveTag.style.display = 'none';
63 | if (negativeTag) negativeTag.style.display = 'none';
64 | }
65 | }
66 |
67 | // Increment counter
68 | function incrementCounter() {
69 | counterValue += incrementAmount;
70 | updateCounterDisplay();
71 | setStatus('Counter incremented');
72 | }
73 |
74 | // Decrement counter
75 | function decrementCounter() {
76 | counterValue -= incrementAmount;
77 | updateCounterDisplay();
78 | setStatus('Counter decremented');
79 | }
80 |
81 | // Reset counter
82 | function resetCounter() {
83 | counterValue = 0;
84 | updateCounterDisplay();
85 | setStatus('Counter reset');
86 | }
87 |
88 | // Initialize dropdown component
89 | function initializeDropdown() {
90 | if (typeof window.Dropdown === 'undefined') {
91 | console.error('Dropdown class not found. Make sure dropdown.js is loaded.');
92 | return;
93 | }
94 |
95 | const dropdownItems = [
96 | { value: '1', label: '1' },
97 | { value: '5', label: '5' },
98 | { value: '10', label: '10' },
99 | { value: '25', label: '25' }
100 | ];
101 |
102 | try {
103 | dropdownInstance = new window.Dropdown('#increment-dropdown', {
104 | items: dropdownItems,
105 | selectedValue: '1',
106 | placeholder: 'Select increment amount',
107 | onSelect: (value) => {
108 | incrementAmount = parseInt(value, 10);
109 | updateCounterDisplay();
110 | setStatus(`Increment amount set to ${incrementAmount}`);
111 | }
112 | });
113 | } catch (error) {
114 | console.error('Error initializing dropdown:', error);
115 | }
116 | }
117 |
118 | // Initialize event listeners
119 | function initializeEventListeners() {
120 | // Sidebar controls
121 | const btnIncrement = document.getElementById('btn-increment');
122 | const btnDecrement = document.getElementById('btn-decrement');
123 | const btnReset = document.getElementById('btn-reset');
124 | const counterLabelInput = document.getElementById('counter-label');
125 |
126 | // Increment button
127 | if (btnIncrement) {
128 | btnIncrement.addEventListener('click', incrementCounter);
129 | }
130 |
131 | // Decrement button
132 | if (btnDecrement) {
133 | btnDecrement.addEventListener('click', decrementCounter);
134 | }
135 |
136 | // Reset button
137 | if (btnReset) {
138 | btnReset.addEventListener('click', resetCounter);
139 | }
140 |
141 | // Label input
142 | if (counterLabelInput) {
143 | counterLabelInput.addEventListener('input', (e) => {
144 | counterLabel = e.target.value || 'Counter';
145 | updateCounterDisplay();
146 | setStatus('Label updated');
147 | });
148 | }
149 | }
150 |
151 | // Initialize help modal
152 | async function initializeHelpModal() {
153 | try {
154 | const response = await fetch('./help-content.html');
155 | const helpContent = await response.text();
156 |
157 | if (typeof HelpModal !== 'undefined') {
158 | HelpModal.init({
159 | triggerSelector: '#btn-help',
160 | content: helpContent,
161 | theme: 'auto'
162 | });
163 | } else {
164 | console.error('HelpModal not found. Make sure help-modal.js is loaded.');
165 | }
166 | } catch (error) {
167 | console.error('Failed to load help content:', error);
168 | if (typeof HelpModal !== 'undefined') {
169 | HelpModal.init({
170 | triggerSelector: '#btn-help',
171 | content: 'Help content could not be loaded. Please check that help-content.html exists.
',
172 | theme: 'auto'
173 | });
174 | }
175 | }
176 | }
177 |
178 | // Initialize everything when DOM is ready
179 | function initialize() {
180 | setStatus('Loading...');
181 |
182 | // Initialize event listeners
183 | initializeEventListeners();
184 |
185 | // Initialize help modal
186 | initializeHelpModal();
187 |
188 | // Initialize dropdown after a short delay to ensure Dropdown class is loaded
189 | setTimeout(() => {
190 | initializeDropdown();
191 | updateCounterDisplay();
192 | setStatus('Ready');
193 | }, 100);
194 | }
195 |
196 | if (document.readyState === 'loading') {
197 | document.addEventListener('DOMContentLoaded', initialize);
198 | } else {
199 | initialize();
200 | }
201 | })();
202 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Bespoke Simulation Template
2 |
3 | This directory contains a template for creating embedded applications that share a consistent design system and user experience.
4 |
5 | ## Components
6 |
7 | ### 1. Design System Integration
8 | This template uses the CodeSignal Design System located in `client/design-system/`:
9 | - **Foundations**: Colors, spacing, typography tokens
10 | - **Components**: Buttons, boxes, inputs, dropdowns, tags
11 | - Light and dark theme support (automatic)
12 | - See the [design system repository](https://github.com/CodeSignal/learn_bespoke-design-system) for full documentation
13 |
14 | ### 2. `client/bespoke-template.css`
15 | Template-specific CSS providing:
16 | - Layout components (header, sidebar, main-layout)
17 | - Utility classes (row, spacer, status)
18 | - Temporary components (modals, form elements) - will be replaced when design system adds them
19 |
20 | ### 3. `client/index.html`
21 | A base HTML template that includes:
22 | - Navigation header with app name and help button
23 | - Main layout structure (sidebar + content area)
24 | - Help modal integration
25 | - Proper CSS and JavaScript loading
26 |
27 | ### 4. `client/help-modal.js`
28 | A dependency-free JavaScript module for the help modal system:
29 | - Consistent modal behavior across all apps
30 | - Keyboard navigation (ESC to close)
31 | - Focus management
32 | - Custom event system
33 |
34 | ### 5. `client/help-content-template.html`
35 | A template for creating consistent help content:
36 | - Table of contents navigation
37 | - Standardized section structure
38 | - FAQ with collapsible details
39 | - Image integration guidelines
40 |
41 | ## Usage Instructions
42 |
43 | ### Setting Up a New Application
44 |
45 | 1. **Clone the repository**
46 | 2. **Ensure the design-system submodule is initialized**:
47 | ```bash
48 | git submodule update --init --recursive
49 | ```
50 |
51 | 3. **Customize the HTML template** by replacing placeholders:
52 | - `` - Your application title
53 | - `` - Your application name (appears in header)
54 | - `` - Any additional header elements
55 | - `` - Your main content area
56 | - `` - Links to your app-specific CSS files
57 | - `` - Links to your app-specific JavaScript files
58 |
59 | 3. **Use Design System Components**
60 | The template uses design system components directly. Use these classes:
61 | - Buttons: `button button-primary`, `button button-secondary`, `button button-danger`, `button button-text`
62 | - Boxes/Cards: `box card` for card containers
63 | - Inputs: Add `input` class to input elements: ` `
64 |
65 | 4. **Implement your application logic**. You can use Cursor or other agents for it. There is a file called `AGENTS.md` that contains context LLM can use.
66 | 5. **Customise your help content** using the help content template
67 | 3. **Use Design System Components**
68 | The template uses design system components directly. Use these classes:
69 | - Buttons: `button button-primary`, `button button-secondary`, `button button-danger`, `button button-text`
70 | - Boxes/Cards: `box card` for card containers
71 | - Inputs: Add `input` class to input elements: ` `
72 |
73 | 4. **Implement your application logic**. You can use Cursor or other agents for it. There is a file called `AGENTS.md` that contains context LLM can use.
74 | 5. **Customise your help content** using the help content template
75 |
76 | ### Customizing Help Content
77 |
78 | Use the `help-content-template.html` as a starting point:
79 |
80 | 1. **Replace placeholders** like `` with your actual content
81 | 2. **Add sections** as needed for your application
82 | 3. **Include images** by placing them in a `help/img/` directory
83 | 4. **Use the provided structure** for consistency across applications
84 |
85 |
86 | ### Help Modal API
87 |
88 | The `HelpModal` class provides several methods:
89 |
90 | ```javascript
91 | // Initialize
92 | const modal = HelpModal.init({
93 | triggerSelector: '#btn-help',
94 | content: helpContent,
95 | theme: 'auto'
96 | });
97 |
98 | // Update content dynamically
99 | modal.updateContent(newHelpContent);
100 |
101 | // Destroy the modal
102 | modal.destroy();
103 | ```
104 |
105 | ## Server
106 |
107 | This template includes a local development server (`server.js`) that provides:
108 | - Static file serving for your application
109 | - WebSocket support for real-time messaging
110 | - A REST API for triggering client-side alerts
111 |
112 | ### Starting the Server
113 |
114 | ```bash
115 | # Local development
116 | npm run start:dev # Vite + API for local development
117 | # Production
118 | npm run build # Create production build in dist/
119 | npm run start:prod # Serve built assets from dist/
120 | ```
121 |
122 |
123 | ### Environment Variables
124 |
125 | The server supports the following environment variables:
126 |
127 | - **`PORT`** - Server port number
128 | - Development: Can be set to any port (e.g., `PORT=3001`), defaulting to `3000`
129 | - Production: Ignored (always `3000` when `IS_PRODUCTION=true`)
130 |
131 | - **`IS_PRODUCTION`** - Enables production mode
132 | - Set to `'true'` to enable production mode
133 | - When enabled:
134 | - Server serves static files from `dist/` directory
135 | - Port is forced to `3000`
136 | - Requires `dist/` directory to exist (throws error if missing)
137 |
138 |
139 | ### Vite Build System
140 |
141 | This project uses [Vite](https://vitejs.dev/) as the build tool for fast development and optimized production builds.
142 |
143 | #### Build Process
144 |
145 | Running `npm run build` executes `vite build`, which:
146 | - Reads source files from the `client/` directory (configured in `vite.config.js`)
147 | - Processes and bundles JavaScript, CSS, and other assets
148 | - Outputs optimized production files to the `dist/` directory
149 | - Generates hashed filenames for cache busting
150 |
151 | ### WebSocket Messaging API
152 |
153 | The server provides a `POST /message` endpoint that allows you to send real-time messages to connected clients. This can be used to signal changes in the client during events like "Run" or "Submit". When a message is sent, the preview window with the application open will display an alert with the message.
154 |
155 | It uses the `ws` package, so if you want to use it, install the packages (but this is optional).
156 |
157 | ```
158 | npm install
159 | ```
160 |
161 | #### Endpoint: `POST /message`
162 |
163 | **Request Format:**
164 | ```json
165 | {
166 | "message": "Your message here"
167 | }
168 | ```
169 |
170 | **Example using curl:**
171 | ```bash
172 | curl -X POST http://localhost:3000/message \
173 | -H "Content-Type: application/json" \
174 | -d '{"message": "Hello from the server!"}'
175 | ```
176 |
177 | ## CI/CD and Automated Releases
178 |
179 | This template includes a GitHub Actions workflow (`.github/workflows/build-release.yml`) that automatically builds and releases your application when you push to the `main` branch.
180 |
181 | ### How It Works
182 |
183 | When you push to `main`, the workflow will:
184 |
185 | 1. **Build the project** - Runs `npm run build` to create production assets in `dist/`
186 | 2. **Create a release tarball** - Packages `dist/`, `package.json`, `server.js`, and production `node_modules/` into `release.tar.gz`
187 | 3. **Create a GitHub Release** - Automatically creates a new release tagged as `v{run_number}` with the tarball attached
188 |
189 | ### Release Contents
190 |
191 | The release tarball (`release.tar.gz`) contains everything needed to deploy the application:
192 | - `dist/` - Built production assets
193 | - `package.json` - Project dependencies and scripts
194 | - `server.js` - Production server
195 | - `node_modules/` - Production dependencies only
196 |
197 | ### Using Releases
198 |
199 | To deploy a release:
200 |
201 | 1. Download `release.tar.gz` from the latest GitHub Release (e.g. with `wget`)
202 | 2. Extract (and remove) the tarball: `tar -xzf release.tar.gz && rm release.tar.gz`
203 | 3. Start the production server: `npm run start:prod`
204 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | const http = require('http');
2 | const fs = require('fs');
3 | const path = require('path');
4 | const url = require('url');
5 |
6 | // Try to load WebSocket module, fallback if not available
7 | let WebSocket = null;
8 | let isWebSocketAvailable = false;
9 | try {
10 | WebSocket = require('ws');
11 | isWebSocketAvailable = true;
12 | console.log('WebSocket support enabled');
13 | } catch (error) {
14 | console.log('WebSocket support disabled (ws package not installed)');
15 | console.log('Install with: npm install ws');
16 | }
17 |
18 | const DIST_DIR = path.join(__dirname, 'dist');
19 | // Check if IS_PRODUCTION is set to true
20 | const isProduction = process.env.IS_PRODUCTION === 'true';
21 | // In production mode, dist directory must exist
22 | if (isProduction && !fs.existsSync(DIST_DIR)) {
23 | throw new Error(`Production mode enabled but dist directory does not exist: ${DIST_DIR}`);
24 | }
25 | // Force port 3000 in production, otherwise use PORT environment variable or default to 3000
26 | const PORT = isProduction ? 3000 : (process.env.PORT || 3000);
27 |
28 | // Track connected WebSocket clients
29 | const wsClients = new Set();
30 |
31 | // MIME types for different file extensions
32 | const mimeTypes = {
33 | '.html': 'text/html',
34 | '.js': 'text/javascript',
35 | '.css': 'text/css',
36 | '.json': 'application/json',
37 | '.png': 'image/png',
38 | '.jpg': 'image/jpeg',
39 | '.jpeg': 'image/jpeg',
40 | '.gif': 'image/gif',
41 | '.svg': 'image/svg+xml',
42 | '.ico': 'image/x-icon',
43 | '.woff': 'font/woff',
44 | '.woff2': 'font/woff2',
45 | '.ttf': 'font/ttf',
46 | '.eot': 'application/vnd.ms-fontobject'
47 | };
48 |
49 | // Get MIME type based on file extension
50 | function getMimeType(filePath) {
51 | const ext = path.extname(filePath).toLowerCase();
52 | return mimeTypes[ext] || 'text/plain';
53 | }
54 |
55 | // Serve static files
56 | function serveFile(filePath, res) {
57 | fs.readFile(filePath, (err, data) => {
58 | if (err) {
59 | res.writeHead(404, { 'Content-Type': 'text/plain' });
60 | res.end('File not found');
61 | return;
62 | }
63 |
64 | const mimeType = getMimeType(filePath);
65 | res.writeHead(200, { 'Content-Type': mimeType });
66 | res.end(data);
67 | });
68 | }
69 |
70 | // Handle POST requests
71 | function handlePostRequest(req, res, parsedUrl) {
72 | if (parsedUrl.pathname === '/message') {
73 | let body = '';
74 |
75 | req.on('data', chunk => {
76 | body += chunk.toString();
77 | });
78 |
79 | req.on('end', () => {
80 | try {
81 | const data = JSON.parse(body);
82 | const message = data.message;
83 |
84 | if (!message) {
85 | res.writeHead(400, { 'Content-Type': 'application/json' });
86 | res.end(JSON.stringify({ error: 'Message is required' }));
87 | return;
88 | }
89 |
90 | // Check if WebSocket is available
91 | if (!isWebSocketAvailable) {
92 | res.writeHead(503, { 'Content-Type': 'application/json' });
93 | res.end(JSON.stringify({
94 | error: 'WebSocket functionality not available',
95 | details: 'Install the ws package with: npm install ws'
96 | }));
97 | return;
98 | }
99 |
100 | // Broadcast message to all connected WebSocket clients
101 | wsClients.forEach(client => {
102 | if (client.readyState === WebSocket.OPEN) {
103 | client.send(JSON.stringify({ type: 'message', message: message }));
104 | }
105 | });
106 |
107 | res.writeHead(200, { 'Content-Type': 'application/json' });
108 | res.end(JSON.stringify({ success: true, clientCount: wsClients.size }));
109 |
110 | } catch (error) {
111 | res.writeHead(400, { 'Content-Type': 'application/json' });
112 | res.end(JSON.stringify({ error: 'Invalid JSON' }));
113 | }
114 | });
115 | } else if (parsedUrl.pathname === '/save-stats') {
116 | let body = '';
117 |
118 | req.on('data', chunk => {
119 | body += chunk.toString();
120 | });
121 |
122 | req.on('end', () => {
123 | try {
124 | const clientDir = path.join(__dirname, 'client');
125 | const statsPath = path.join(clientDir, 'stats.txt');
126 |
127 | // Ensure client directory exists
128 | if (!fs.existsSync(clientDir)) {
129 | fs.mkdirSync(clientDir, { recursive: true });
130 | }
131 |
132 | // Write stats to file
133 | fs.writeFileSync(statsPath, body, 'utf8');
134 |
135 | res.writeHead(200, { 'Content-Type': 'application/json' });
136 | res.end(JSON.stringify({ success: true, message: 'Statistics saved' }));
137 |
138 | } catch (error) {
139 | console.error('Error saving statistics:', error);
140 | res.writeHead(500, { 'Content-Type': 'application/json' });
141 | res.end(JSON.stringify({ error: 'Failed to save statistics' }));
142 | }
143 | });
144 | } else {
145 | res.writeHead(404, { 'Content-Type': 'text/plain' });
146 | res.end('Not found');
147 | }
148 | }
149 |
150 | // Create HTTP server
151 | const server = http.createServer((req, res) => {
152 | const parsedUrl = url.parse(req.url, true);
153 | let pathName = parsedUrl.pathname === '/' ? '/index.html' : parsedUrl.pathname;
154 |
155 | // Handle POST requests
156 | if (req.method === 'POST') {
157 | handlePostRequest(req, res, parsedUrl);
158 | return;
159 | }
160 |
161 | // In production mode, serve static files from dist directory
162 | if (isProduction) {
163 | // Strip leading slashes so path.join/resolve can't ignore DIST_DIR
164 | let filePath = path.join(DIST_DIR, pathName.replace(/^\/+/, ''));
165 |
166 | // Security check - prevent directory traversal
167 | const resolvedDistDir = path.resolve(DIST_DIR);
168 | const resolvedFilePath = path.resolve(filePath);
169 | const relativePath = path.relative(resolvedDistDir, resolvedFilePath);
170 |
171 | // Reject if path tries to traverse outside the base directory
172 | if (relativePath.startsWith('..')) {
173 | res.writeHead(403, { 'Content-Type': 'text/plain' });
174 | res.end('Forbidden');
175 | return;
176 | }
177 |
178 | serveFile(filePath, res);
179 | } else {
180 | // Development mode - static files are served by Vite
181 | res.writeHead(404, { 'Content-Type': 'text/plain' });
182 | res.end('Not found (development mode - use Vite dev server `npm run start:dev`)');
183 | }
184 | });
185 |
186 | // Create WebSocket server only if WebSocket is available
187 | // Note: WebSocket upgrade handling is performed automatically by the ws library
188 | // when attached to the HTTP server. The HTTP request handler should NOT send
189 | // a response for upgrade requests - the ws library handles the upgrade internally.
190 | if (isWebSocketAvailable) {
191 | const wss = new WebSocket.Server({
192 | server,
193 | path: '/ws'
194 | });
195 |
196 | wss.on('connection', (ws, req) => {
197 | console.log('New WebSocket client connected');
198 | wsClients.add(ws);
199 |
200 | ws.on('close', () => {
201 | console.log('WebSocket client disconnected');
202 | wsClients.delete(ws);
203 | });
204 |
205 | ws.on('error', (error) => {
206 | console.error('WebSocket error:', error);
207 | wsClients.delete(ws);
208 | });
209 | });
210 | }
211 |
212 | // Start server
213 | server.listen(PORT, () => {
214 | console.log(`Server running at http://localhost:${PORT}`);
215 | if (isProduction) {
216 | console.log(`Serving static files from: ${DIST_DIR}`);
217 | } else {
218 | console.log(`Development mode - static files served by Vite`);
219 | }
220 | if (isWebSocketAvailable) {
221 | console.log(`WebSocket server running on /ws`);
222 | } else {
223 | console.log(`WebSocket functionality disabled - install 'ws' package to enable`);
224 | }
225 | console.log('Press Ctrl+C to stop the server');
226 | });
227 |
228 | // Handle server errors
229 | server.on('error', (err) => {
230 | if (err.code === 'EADDRINUSE') {
231 | console.error(`Port ${PORT} is already in use. Please try a different port.`);
232 | } else {
233 | console.error('Server error:', err);
234 | }
235 | process.exit(1);
236 | });
237 |
238 | // Graceful shutdown
239 | process.on('SIGINT', () => {
240 | console.log('\nShutting down server...');
241 | server.close(() => {
242 | console.log('Server closed');
243 | process.exit(0);
244 | });
245 | });
246 |
--------------------------------------------------------------------------------
/client/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Typing Simulator
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
98 |
99 | Press space to start
100 |
101 |
102 |
103 |
104 |
105 |
106 |
116 |
117 |
118 |
119 |
120 |
123 |
124 |
132 |
133 |
Completed
134 |
Great job! You've finished typing the text.
135 |
Start Over
136 |
137 |
138 |
142 |
143 |
144 |
145 | 0
146 |
147 |
WPM
148 |
149 |
150 |
151 | 0%
152 |
153 |
Accuracy
154 |
155 |
156 |
157 | 0s
158 |
159 |
Total time
160 |
161 |
162 |
163 | 0
164 |
165 |
Total errors
166 |
167 |
168 |
169 | 0
170 |
171 |
Errors left
172 |
173 |
174 |
177 |
178 |
187 |
191 |
192 |
193 |
194 |
195 |
196 |
197 |
198 |
199 |
200 |
201 |
202 |
203 |
206 |
207 |
208 |
--------------------------------------------------------------------------------
/BESPOKE-TEMPLATE.md:
--------------------------------------------------------------------------------
1 | # Bespoke Simulation Template
2 |
3 | This document provides precise implementation instructions for creating
4 | embedded applications using the Bespoke Simulation template. Follow these
5 | instructions exactly to ensure consistency across all applications.
6 | NOTE: Never edit this `BESPOKE-TEMPLATE.md` file. Codebase changes should be reflected in the `AGENTS.md` file.
7 |
8 | ## Required Files Structure
9 |
10 | Every application should include these files in the following order:
11 |
12 | 1. CodeSignal Design System foundations:
13 | - colors/colors.css
14 | - spacing/spacing.css
15 | - typography/typography.css
16 | - components/button/button.css (used in header)
17 | 2. CodeSignal Design System components (optional):
18 | - components/boxes/boxes.css
19 | - components/dropdown/dropdown.css
20 | - components/input/input.css
21 | - components/tags/tags.css
22 | 3. bespoke-template.css (template-specific layout, utilities, temporary
23 | components)
24 | 4. help-modal.js (help system)
25 | 5. app.js (application logic)
26 | 6. server.js (server)
27 |
28 | ## HTML Template Implementation
29 |
30 | 1. REPLACE the following placeholders in index.html EXACTLY as specified:
31 |
32 | a) ``
33 | Replace with your application's page title
34 | Example: "Database Designer" or "Task Manager"
35 |
36 | b) ``
37 | Replace with your application's display name (appears in header)
38 | Example: "Database Designer" or "Task Manager"
39 |
40 | c) ``
41 | Add your application's main content area
42 | Example: `
` or `
`
43 |
44 | d) ``
45 | Add links to your application-specific CSS files
46 | Example: ` `
47 |
48 | e) ``
49 | Add links to your application-specific JavaScript files
50 | Example: ``
51 |
52 | 2. DO NOT modify the core structure (header, script loading order, etc.)
53 |
54 | ## CSS Implementation
55 |
56 | 1. ALWAYS use the `.bespoke` class on the body element for scoping
57 | 2. USE design system components directly with proper classes:
58 | - Buttons: `button button-primary`, `button button-secondary`,
59 | `button button-danger`, `button button-text`
60 | - Boxes/Cards: `box card` for card containers
61 | - Inputs: Add `input` class to input elements:
62 | ` `
63 | 3. USE design system CSS custom properties for styling:
64 | - Colors: `--Colors-*` (e.g., `--Colors-Primary-Default`,
65 | `--Colors-Text-Body-Default`)
66 | - Spacing: `--UI-Spacing-*` (e.g., `--UI-Spacing-spacing-ml`,
67 | `--UI-Spacing-spacing-xl`)
68 | - Typography: `--Fonts-*` (e.g., `--Fonts-Body-Default-md`,
69 | `--Fonts-Headlines-sm`)
70 | - Borders: `--UI-Radius-*` (e.g., `--UI-Radius-radius-s`,
71 | `--UI-Radius-radius-m`)
72 | - Font families: `--body-family`, `--heading-family`
73 | 4. FOR custom styling, create app-specific CSS files
74 | 5. OVERRIDE design system variables in your app-specific CSS, not in
75 | bespoke-template.css
76 | 6. FOLLOW design system naming conventions for consistency
77 |
78 | ## JavaScript Implementation
79 |
80 | 1. HELP MODAL SETUP:
81 | a) Create help content using help-content-template.html as reference
82 | b) Initialize HelpModal with:
83 | - triggerSelector: `'#btn-help'`
84 | - content: your help content (string or loaded from file)
85 | - theme: `'auto'`
86 |
87 | 2. STATUS MANAGEMENT:
88 | a) Use the provided setStatus() function for status updates
89 | b) Update status for: loading, saving, errors, user actions
90 | c) Keep status messages concise and informative
91 |
92 | ## Error Handling Requirements
93 |
94 | 1. WRAP all async operations in try-catch blocks
95 | 2. PROVIDE meaningful error messages to users
96 | 3. LOG errors to console for debugging
97 | 4. IMPLEMENT retry logic for network operations
98 | 5. HANDLE localStorage quota exceeded errors
99 | 6. VALIDATE data before saving operations
100 |
101 | ## Status Message Conventions
102 |
103 | Use these EXACT status messages for consistency:
104 |
105 | - "Ready" - Application loaded successfully
106 | - "Loading..." - Data is being loaded
107 | - "Saving..." - Data is being saved
108 | - "Changes saved" - Auto-save completed successfully
109 | - "Save failed (will retry)" - Server save failed, will retry
110 | - "Failed to load data" - Data loading failed
111 | - "Auto-save initialized" - Auto-save system started
112 |
113 | ## File Naming Conventions
114 |
115 | 1. CSS files: kebab-case (e.g., my-app.css, task-manager.css)
116 | 2. JavaScript files: kebab-case (e.g., my-app.js, task-manager.js)
117 | 3. Data files: kebab-case (e.g., solution.json, initial-data.json)
118 | 4. Image files: kebab-case (e.g., overview.png, help-icon.svg)
119 |
120 | ---
121 |
122 | # Bespoke Template Design System Guidelines
123 |
124 | This section explains how to use the CodeSignal Design System with the
125 | Bespoke template for embedded applications.
126 |
127 | ## Overview
128 |
129 | The Bespoke template uses the CodeSignal Design System for components and
130 | tokens, with template-specific layout and utilities. All styles are scoped
131 | under the `.bespoke` class to prevent interference with parent site styles.
132 | The template uses design system components directly where available, and
133 | provides temporary components (modals, form elements) that will be replaced
134 | when the design system adds them.
135 |
136 | ## Basic Usage
137 |
138 | ### 1. Include the CSS
139 |
140 | ```html
141 |
142 | ```
143 |
144 | ### 2. Wrap Your Application
145 |
146 | ```html
147 |
148 |
149 |
150 | ```
151 |
152 | ### 3. Use the Component Classes
153 |
154 | ```html
155 |
156 |
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 |
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 | Field Name
226 |
227 |
228 |
229 |
230 |
231 |
232 | Checkbox Label
233 |
234 | ```
235 |
236 | #### Input Fields
237 |
238 | ```html
239 |
240 |
241 |
242 |
243 |
244 | Option 1
245 | Option 2
246 |
247 |
248 |
249 |
250 |
251 |
252 |
253 |
254 |
255 | Option A
256 |
257 |
258 |
259 | Option B
260 |
261 |
262 |
263 |
264 |
265 |
266 |
267 | Small
268 |
269 |
270 |
271 | Large
272 |
273 |
274 |
275 |
276 |
277 |
278 |
279 |
280 |
281 |
282 |
283 |
284 | Enable notifications
285 |
286 | ```
287 |
288 | #### Buttons
289 |
290 | ```html
291 |
292 | Click Me
293 |
294 |
295 | Primary Action
296 | Delete
297 | Secondary
298 |
299 |
300 | Link Button
301 | ```
302 |
303 | ### Modal Components
304 |
305 | #### Basic Modal
306 |
307 | ```html
308 |
309 |
310 |
311 |
315 |
316 |
Modal content goes here
317 |
318 |
319 |
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 |
400 |
401 |
402 |
413 |
414 |
415 |
416 |
417 |
418 |
419 | ```
420 |
421 | ## Best Practices
422 |
423 | 1. **Always wrap in `.bespoke`**: This prevents style conflicts with the parent
424 | site
425 | 2. **Use design system components directly**: Use proper class combinations like
426 | `button button-primary`
427 | 3. **Use semantic HTML**: Combine with proper HTML elements for accessibility
428 | 4. **Customize via design system CSS variables**: Override design system
429 | variables in your app-specific CSS
430 | 5. **Test in both themes**: Ensure your app works in light and dark modes
431 | 6. **Note on temporary components**: Modal and form components in
432 | `bespoke-template.css` are temporary and will be replaced when the design
433 | system adds them
434 |
435 |
--------------------------------------------------------------------------------
/client/bespoke-template.css:
--------------------------------------------------------------------------------
1 | /* ===== BESPOKE TEMPLATE CSS ===== */
2 | /* Template-specific components using CodeSignal Design System tokens */
3 | /* This file provides layout, utilities, and temporary components not yet in the design system */
4 |
5 | /* ===== GLOBAL STYLES ===== */
6 |
7 | /* Ensure page never scrolls */
8 | html, body {
9 | overflow: hidden;
10 | height: 100%;
11 | margin: 0;
12 | padding: 0;
13 | }
14 |
15 | /* ===== LAYOUT COMPONENTS ===== */
16 |
17 | /* Bespoke wrapper for scoping */
18 | .bespoke {
19 | font-family: var(--body-family);
20 | color: var(--Colors-Text-Body-Default);
21 | background: var(--Colors-Backgrounds-Main-Default);
22 | line-height: 1.6;
23 | margin: 0;
24 | padding: 0;
25 | display: flex;
26 | flex-direction: column;
27 | height: 100vh;
28 | overflow: hidden;
29 | }
30 |
31 | .bespoke * {
32 | box-sizing: border-box;
33 | }
34 |
35 | /* Header */
36 | .bespoke .header {
37 | display: flex;
38 | align-items: center;
39 | gap: var(--UI-Spacing-spacing-ml);
40 | padding: var(--UI-Spacing-spacing-s) var(--UI-Spacing-spacing-mxl);
41 | border-bottom: 1px solid var(--Colors-Stroke-Default);
42 | background: var(--Colors-Backgrounds-Main-Top);
43 | width: 100%;
44 | flex: 0 0 auto; /* Don't grow or shrink */
45 | }
46 |
47 | .bespoke .header h1 {
48 | font-size: var(--Fonts-Body-Default-lg);
49 | margin: 0;
50 | font-weight: 600;
51 | font-family: var(--heading-family);
52 | color: var(--Colors-Text-Body-Strongest);
53 | }
54 |
55 | .bespoke .header .status {
56 | font-size: var(--Fonts-Body-Default-xs);
57 | color: var(--Colors-Text-Body-Medium);
58 | }
59 |
60 | /* Main Layout */
61 | .bespoke .main-layout {
62 | display: grid;
63 | grid-template-columns: 300px 1fr;
64 | flex: 1 1 auto; /* Take remaining space */
65 | overflow: hidden;
66 | min-height: 0;
67 | }
68 |
69 | .bespoke .sidebar {
70 | padding: var(--UI-Spacing-spacing-s);
71 | overflow: auto;
72 | border-right: 1px solid var(--Colors-Stroke-Default);
73 | background: var(--Colors-Backgrounds-Main-Default);
74 | }
75 |
76 | .bespoke .content-area {
77 | width: 100%;
78 | height: 100%;
79 | overflow: hidden;
80 | min-height: 0;
81 | }
82 |
83 | /* ===== UTILITY CLASSES ===== */
84 |
85 | /* Flexbox Utilities */
86 | .bespoke .row {
87 | display: flex;
88 | align-items: center;
89 | gap: var(--UI-Spacing-spacing-s);
90 | }
91 |
92 | .bespoke .row-between {
93 | display: flex;
94 | align-items: center;
95 | justify-content: space-between;
96 | gap: var(--UI-Spacing-spacing-ml);
97 | }
98 |
99 | .bespoke .spacer {
100 | flex: 1;
101 | }
102 |
103 | /* Dividers */
104 | .bespoke hr {
105 | border: none;
106 | border-top: 0.5px solid var(--Colors-Stroke-Default);
107 | margin: var(--UI-Spacing-spacing-ml) 0;
108 | }
109 |
110 | /* ===== TEMPORARY COMPONENTS (TODO: Replace when design system adds these) ===== */
111 |
112 | /* Modal Components - TODO: Remove when design system adds modal component */
113 | .bespoke .modal {
114 | position: fixed;
115 | top: 0;
116 | left: 0;
117 | width: 100%;
118 | height: 100%;
119 | z-index: 500;
120 | display: flex;
121 | align-items: center;
122 | justify-content: center;
123 | padding: var(--UI-Spacing-spacing-xl);
124 | box-sizing: border-box;
125 | margin: 0;
126 | }
127 |
128 | .bespoke .modal-backdrop {
129 | position: absolute;
130 | top: 0;
131 | left: 0;
132 | width: 100%;
133 | height: 100%;
134 | background: rgba(0, 0, 0, 0.5);
135 | backdrop-filter: blur(2px);
136 | }
137 |
138 | .bespoke .modal-content {
139 | position: relative;
140 | background: var(--Colors-Backgrounds-Main-Top);
141 | border: 1px solid var(--Colors-Stroke-Default);
142 | border-radius: var(--UI-Radius-radius-m);
143 | max-width: 800px;
144 | width: calc(100% - 40px);
145 | max-height: 90vh;
146 | display: flex;
147 | flex-direction: column;
148 | box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
149 | margin: 0;
150 | }
151 |
152 | .bespoke .modal-header {
153 | display: flex;
154 | align-items: center;
155 | justify-content: space-between;
156 | padding: var(--UI-Spacing-spacing-xl);
157 | border-bottom: 1px solid var(--Colors-Stroke-Default);
158 | background: var(--Colors-Backgrounds-Main-Top);
159 | border-radius: var(--UI-Radius-radius-m) var(--UI-Radius-radius-m) 0 0;
160 | }
161 |
162 | .bespoke .modal-header h2 {
163 | margin: 0;
164 | font-size: var(--Fonts-Body-Default-xl);
165 | color: var(--Colors-Text-Body-Strongest);
166 | font-family: var(--heading-family);
167 | font-weight: 500;
168 | }
169 |
170 | .bespoke .modal-close {
171 | background: none;
172 | border: none;
173 | font-size: var(--Fonts-Body-Default-xxxl);
174 | color: var(--Colors-Text-Body-Medium);
175 | cursor: pointer;
176 | padding: var(--UI-Spacing-spacing-xxs) var(--UI-Spacing-spacing-s);
177 | border-radius: var(--UI-Radius-radius-xxs);
178 | line-height: 1;
179 | transition: all 0.2s ease;
180 | }
181 |
182 | .bespoke .modal-close:hover {
183 | background: var(--Colors-Backgrounds-Main-Medium);
184 | color: var(--Colors-Text-Body-Default);
185 | }
186 |
187 | .bespoke .modal-body {
188 | padding: var(--UI-Spacing-spacing-xl);
189 | overflow-y: auto;
190 | flex: 1;
191 | line-height: 1.6;
192 | }
193 |
194 | .bespoke .modal-body h2 {
195 | margin-top: var(--UI-Spacing-spacing-xxxl);
196 | margin-bottom: var(--UI-Spacing-spacing-ml);
197 | font-size: var(--Fonts-Body-Default-xl);
198 | color: var(--Colors-Text-Body-Strongest);
199 | font-family: var(--heading-family);
200 | font-weight: 500;
201 | }
202 |
203 | .bespoke .modal-body h2:first-child {
204 | margin-top: 0;
205 | }
206 |
207 | .bespoke .modal-body h3 {
208 | margin-top: var(--UI-Spacing-spacing-xl);
209 | margin-bottom: var(--UI-Spacing-spacing-s);
210 | font-size: var(--Fonts-Body-Default-lg);
211 | color: var(--Colors-Text-Body-Strongest);
212 | font-family: var(--heading-family);
213 | font-weight: 500;
214 | }
215 |
216 | .bespoke .modal-body p,
217 | .bespoke .modal-body li {
218 | color: var(--Colors-Text-Body-Default);
219 | margin-bottom: var(--UI-Spacing-spacing-s);
220 | }
221 |
222 | .bespoke .modal-body ul,
223 | .bespoke .modal-body ol {
224 | margin: var(--UI-Spacing-spacing-s) 0 var(--UI-Spacing-spacing-ml) 0;
225 | padding-left: var(--UI-Spacing-spacing-xl);
226 | }
227 |
228 | .bespoke .modal-body code {
229 | font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
230 | background: rgba(148, 163, 184, 0.12);
231 | border-radius: var(--UI-Radius-radius-xxs);
232 | padding: 0.15em 0.35em;
233 | font-size: var(--Fonts-Body-Default-xs);
234 | }
235 |
236 | .bespoke .modal-body pre {
237 | font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
238 | background: rgba(148, 163, 184, 0.12);
239 | border-radius: var(--UI-Radius-radius-xs);
240 | padding: var(--UI-Spacing-spacing-ms);
241 | overflow: auto;
242 | margin: var(--UI-Spacing-spacing-ml) 0;
243 | }
244 |
245 | .bespoke .modal-body img,
246 | .bespoke .modal-body video {
247 | max-width: 100%;
248 | height: auto;
249 | border-radius: var(--UI-Radius-radius-xs);
250 | border: 1px solid var(--Colors-Stroke-Default);
251 | margin: var(--UI-Spacing-spacing-ml) 0;
252 | }
253 |
254 | /* Form Elements - TODO: Remove when design system adds form components */
255 | .bespoke label {
256 | display: flex;
257 | flex-direction: column;
258 | gap: var(--UI-Spacing-spacing-xxs);
259 | margin: var(--UI-Spacing-spacing-ms) 0 var(--UI-Spacing-spacing-s) 0;
260 | }
261 |
262 | .bespoke label.row {
263 | flex-direction: row;
264 | align-items: center;
265 | gap: var(--UI-Spacing-spacing-s);
266 | margin: var(--UI-Spacing-spacing-s) 0;
267 | }
268 |
269 | /* Textarea */
270 | .bespoke textarea {
271 | padding: var(--UI-Spacing-spacing-ms);
272 | border: 1px solid var(--Colors-Input-Border-Default);
273 | border-radius: var(--UI-Radius-radius-s);
274 | background: var(--Colors-Input-Background-Default);
275 | color: var(--Colors-Input-Text-Default);
276 | font-family: var(--body-family);
277 | font-size: var(--Fonts-Body-Default-md);
278 | min-height: 6rem;
279 | resize: vertical;
280 | transition: border-color 0.2s ease;
281 | }
282 |
283 | .bespoke textarea:hover {
284 | border-color: var(--Colors-Input-Border-Hover);
285 | }
286 |
287 | .bespoke textarea:focus-visible {
288 | outline: none;
289 | border-color: var(--Colors-Input-Border-Focus);
290 | box-shadow: 0 0 0 4px var(--Colors-Input-Shadow-Focus);
291 | }
292 |
293 | .bespoke textarea::placeholder {
294 | color: var(--Colors-Input-Text-Placeholder);
295 | opacity: 1;
296 | }
297 |
298 | /* Select styling - TODO: Remove when design system adds select component */
299 | .bespoke select {
300 | -webkit-appearance: none;
301 | -moz-appearance: none;
302 | appearance: none;
303 | background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6,9 12,15 18,9'%3e%3c/polyline%3e%3c/svg%3e");
304 | background-repeat: no-repeat;
305 | background-position: right 0.75rem center;
306 | background-size: 1rem;
307 | padding-right: 3rem;
308 | }
309 |
310 | .bespoke select::-ms-expand {
311 | display: none;
312 | }
313 |
314 | /* Radio Buttons - TODO: Remove when design system adds radio component */
315 | .bespoke input[type="radio"] {
316 | appearance: none;
317 | width: 1rem;
318 | height: 1rem;
319 | border: 2px solid var(--Colors-Input-Border-Default);
320 | border-radius: 50%;
321 | background: var(--Colors-Input-Background-Default);
322 | cursor: pointer;
323 | position: relative;
324 | transition: all 0.2s ease;
325 | flex-shrink: 0;
326 | padding: 0;
327 | }
328 |
329 | .bespoke input[type="radio"]:checked {
330 | border-color: var(--Colors-Stroke-Primary);
331 | background: var(--Colors-Stroke-Primary);
332 | }
333 |
334 | .bespoke input[type="radio"]:checked::after {
335 | content: '';
336 | position: absolute;
337 | top: 50%;
338 | left: 50%;
339 | transform: translate(-50%, -50%);
340 | width: 0.375rem;
341 | height: 0.375rem;
342 | border-radius: 50%;
343 | background: white;
344 | }
345 |
346 | .bespoke input[type="radio"]:hover {
347 | border-color: var(--Colors-Input-Border-Hover);
348 | }
349 |
350 | .bespoke input[type="radio"]:focus-visible {
351 | outline: none;
352 | border-color: var(--Colors-Input-Border-Focus);
353 | box-shadow: 0 0 0 3px var(--Colors-Input-Shadow-Focus);
354 | }
355 |
356 | .bespoke .radio-group {
357 | display: flex;
358 | flex-direction: column;
359 | gap: var(--UI-Spacing-spacing-s);
360 | }
361 |
362 | .bespoke .radio-group.horizontal {
363 | flex-direction: row;
364 | align-items: center;
365 | gap: var(--UI-Spacing-spacing-ml);
366 | }
367 |
368 | /* Checkbox - TODO: Remove when design system adds checkbox component */
369 | .bespoke input[type="checkbox"] {
370 | padding: 0;
371 | margin: 0;
372 | }
373 |
374 | /* Toggle Switch - TODO: Remove when design system adds toggle component */
375 | .bespoke .toggle {
376 | position: relative;
377 | display: inline-block;
378 | width: 3rem;
379 | height: 1.5rem;
380 | }
381 |
382 | .bespoke .toggle-input {
383 | opacity: 0;
384 | width: 0;
385 | height: 0;
386 | }
387 |
388 | .bespoke .toggle-slider {
389 | position: absolute;
390 | cursor: pointer;
391 | top: 0;
392 | left: 0;
393 | right: 0;
394 | bottom: 0;
395 | background-color: var(--Colors-Stroke-Medium);
396 | transition: 0.3s;
397 | border-radius: 1.5rem;
398 | }
399 |
400 | .bespoke .toggle-slider:before {
401 | position: absolute;
402 | content: "";
403 | height: 1.125rem;
404 | width: 1.125rem;
405 | left: 0.1875rem;
406 | bottom: 0.1875rem;
407 | background-color: white;
408 | transition: 0.3s;
409 | border-radius: 50%;
410 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
411 | }
412 |
413 | .bespoke .toggle-input:checked + .toggle-slider {
414 | background-color: var(--Colors-Primary-Default);
415 | }
416 |
417 | .bespoke .toggle-input:checked + .toggle-slider:before {
418 | transform: translateX(1.5rem);
419 | }
420 |
421 | .bespoke .toggle-input:focus + .toggle-slider {
422 | box-shadow: 0 0 0 3px var(--Colors-Input-Shadow-Focus);
423 | }
424 |
425 | .bespoke .toggle-input:disabled + .toggle-slider {
426 | opacity: 0.5;
427 | cursor: not-allowed;
428 | }
429 |
430 | .bespoke .toggle-label {
431 | margin-left: var(--UI-Spacing-spacing-s);
432 | font-size: var(--Fonts-Body-Default-xs);
433 | color: var(--Colors-Text-Body-Default);
434 | cursor: pointer;
435 | }
436 |
437 | /* Dark mode adjustments */
438 | @media (prefers-color-scheme: dark) {
439 | .bespoke .modal-backdrop {
440 | background: rgba(0, 0, 0, 0.7);
441 | }
442 |
443 | .bespoke .modal-body code,
444 | .bespoke .modal-body pre {
445 | background: rgba(148, 163, 184, 0.2);
446 | }
447 |
448 | .bespoke select {
449 | background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23c1c7d7' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6,9 12,15 18,9'%3e%3c/polyline%3e%3c/svg%3e");
450 | }
451 | }
452 |
453 | /* Responsive Design */
454 | @media (max-width: 768px) {
455 | .bespoke .header {
456 | padding: var(--UI-Spacing-spacing-xs) var(--UI-Spacing-spacing-lg);
457 | gap: var(--UI-Spacing-spacing-md);
458 | }
459 |
460 | .bespoke .header h1 {
461 | font-size: var(--Fonts-Body-Default-md);
462 | }
463 |
464 | .bespoke .header .status {
465 | font-size: var(--Fonts-Body-Default-xxs);
466 | }
467 |
468 | .bespoke .main-layout {
469 | grid-template-columns: 1fr;
470 | grid-template-rows: auto 1fr;
471 | }
472 |
473 | .bespoke .sidebar {
474 | border-right: none;
475 | border-bottom: 1px solid var(--Colors-Stroke-Default);
476 | }
477 |
478 | .bespoke .modal {
479 | padding: var(--UI-Spacing-spacing-s);
480 | }
481 |
482 | .bespoke .modal-content {
483 | max-height: 95vh;
484 | }
485 |
486 | .bespoke .modal-header {
487 | padding: var(--UI-Spacing-spacing-mxl);
488 | }
489 |
490 | .bespoke .modal-body {
491 | padding: var(--UI-Spacing-spacing-mxl);
492 | }
493 |
494 | .bespoke .modal-header h2 {
495 | font-size: var(--Fonts-Body-Default-lg);
496 | }
497 | }
498 |
--------------------------------------------------------------------------------
/client/typing-simulator.css:
--------------------------------------------------------------------------------
1 | /* typing-simulator.css */
2 |
3 | /* Override main-layout grid to use full width (no sidebar in typing simulator) */
4 | .bespoke .main-layout {
5 | grid-template-columns: 1fr;
6 | overflow: hidden; /* Prevent scrolling */
7 | }
8 |
9 | /* Racing Track Game Styles */
10 | .bespoke .racing-track-container {
11 | flex: 1 1 auto;
12 | position: relative;
13 | /* Remove width: 100% - let it be auto-width so it can be centered */
14 | min-height: 0;
15 | display: flex;
16 | flex-direction: column;
17 | align-items: center; /* Center the track horizontally */
18 | justify-content: flex-start;
19 | margin-top: var(--UI-Spacing-spacing-lg);
20 | }
21 |
22 | .bespoke .racing-track {
23 | position: relative;
24 | width: auto; /* Width will be set dynamically via JavaScript */
25 | min-width: 70px; /* Minimum width to match text start position */
26 | height: 300px;
27 | background: linear-gradient(to bottom,
28 | var(--Colors-Box-Background-Secondary) 0%,
29 | var(--Colors-Box-Background-Secondary) 48%,
30 | #FFFFFF 48%,
31 | #FFFFFF 52%,
32 | var(--Colors-Box-Background-Secondary) 52%,
33 | var(--Colors-Box-Background-Secondary) 100%
34 | );
35 | border: 2px solid var(--Colors-Stroke-Default);
36 | border-radius: var(--UI-Radius-radius-m);
37 | overflow: hidden;
38 | margin-bottom: var(--UI-Spacing-spacing-md);
39 | /* Centering is handled by parent container's align-items: center */
40 | }
41 |
42 | .bespoke .racing-track-lane {
43 | position: absolute;
44 | left: 0;
45 | width: 100%; /* Default width, will be updated dynamically via JavaScript */
46 | height: 75px;
47 | border-bottom: 2px dashed var(--Colors-Stroke-Light);
48 | }
49 |
50 | .bespoke .racing-track-lane[data-lane="0"] {
51 | top: 0;
52 | }
53 |
54 | .bespoke .racing-track-lane[data-lane="1"] {
55 | top: 75px;
56 | }
57 |
58 | .bespoke .racing-track-lane[data-lane="2"] {
59 | top: 150px;
60 | }
61 |
62 | .bespoke .racing-track-lane[data-lane="3"] {
63 | top: 225px;
64 | border-bottom: none;
65 | }
66 |
67 | .bespoke .racing-car {
68 | position: absolute;
69 | left: 20px;
70 | top: 50%;
71 | transform: translateY(-50%);
72 | transition: left 0.1s linear;
73 | z-index: 25; /* Above finish line (z-index: 20) */
74 | }
75 |
76 | .bespoke .racing-car-player {
77 | z-index: 30; /* Player car above opponents */
78 | }
79 |
80 | .bespoke .racing-text-overlay {
81 | position: absolute;
82 | top: 0;
83 | left: 70px; /* Start after car (car at 20px + car width 40px + 10px gap) */
84 | right: 0;
85 | height: 75px; /* Same height as lane 0 */
86 | pointer-events: none;
87 | z-index: 5;
88 | display: flex;
89 | align-items: center;
90 | padding: 0 var(--UI-Spacing-spacing-xl) 0 0;
91 | overflow-x: auto;
92 | overflow-y: hidden;
93 | scroll-behavior: smooth;
94 | scrollbar-width: none; /* Firefox */
95 | -ms-overflow-style: none; /* IE and Edge */
96 | }
97 |
98 | .bespoke .racing-text-overlay::-webkit-scrollbar {
99 | display: none; /* Chrome, Safari, Opera */
100 | }
101 |
102 | .bespoke .racing-typing-text {
103 | width: 100%;
104 | font-family: var(--body-family);
105 | font-size: var(--Fonts-Body-Default-lg);
106 | line-height: 1.5;
107 | white-space: nowrap;
108 | overflow: visible;
109 | user-select: none;
110 | display: inline-block;
111 | min-width: 100%;
112 | }
113 |
114 | .bespoke .racing-typing-text .char-pending {
115 | color: var(--Colors-Text-Body-Light);
116 | }
117 |
118 | .bespoke .racing-typing-text .char-correct {
119 | color: var(--Colors-Text-Body-Strongest);
120 | }
121 |
122 | .bespoke .racing-typing-text .char-incorrect {
123 | color: var(--Colors-Alert-Error-Default);
124 | }
125 |
126 | .bespoke .racing-typing-text .char-incorrect.char-space {
127 | background-color: color-mix(in srgb, var(--Colors-Alert-Error-Default) 30%, transparent);
128 | border-radius: 2px;
129 | padding: 0 2px;
130 | }
131 |
132 | .bespoke .racing-typing-text .cursor-position {
133 | position: relative;
134 | }
135 |
136 | .bespoke .racing-typing-text .cursor-position::before {
137 | display: none; /* Hide cursor in racing game - car acts as cursor */
138 | }
139 |
140 | .bespoke .racing-finish-line {
141 | position: absolute;
142 | right: 0; /* Position at right edge of track */
143 | top: 0;
144 | bottom: 0;
145 | width: 4px;
146 | background: repeating-linear-gradient(
147 | to bottom,
148 | #000000 0px,
149 | #000000 10px,
150 | #FFFFFF 10px,
151 | #FFFFFF 20px
152 | );
153 | z-index: 20;
154 | box-shadow: -2px 0 4px rgba(0, 0, 0, 0.2);
155 | }
156 |
157 | .bespoke .typing-container {
158 | position: relative;
159 | width: 100%;
160 | height: 100%;
161 | display: flex;
162 | flex-direction: column;
163 | align-items: center;
164 | justify-content: flex-start;
165 | padding: var(--UI-Spacing-spacing-xl);
166 | overflow: hidden; /* Prevent scrolling */
167 | min-height: 0; /* Allow flexbox to shrink */
168 | }
169 |
170 | /* Ensure racing track container is centered */
171 | .bespoke .typing-container > .racing-track-container {
172 | align-self: center;
173 | }
174 |
175 | /* Keyboard and Stats Wrapper */
176 | .bespoke .keyboard-stats-wrapper {
177 | position: relative;
178 | width: 100%;
179 | max-width: 900px;
180 | margin: var(--UI-Spacing-spacing-xl) auto 0;
181 | flex: 0 0 auto; /* Don't grow, don't shrink */
182 | min-height: 0; /* Allow flexbox to shrink */
183 | }
184 |
185 | /* Real-time Stats Container */
186 | .bespoke .realtime-stats-container {
187 | display: none;
188 | position: absolute;
189 | left: 0;
190 | bottom: 0;
191 | flex-direction: column;
192 | gap: 2px;
193 | padding: var(--UI-Spacing-spacing-md);
194 | background: var(--Colors-Box-Background-Secondary);
195 | border: none;
196 | border-radius: var(--UI-Radius-radius-m);
197 | width: 140px;
198 | margin-right: var(--UI-Spacing-spacing-xl);
199 | z-index: 1;
200 | }
201 |
202 | .bespoke .realtime-stat-item {
203 | display: flex;
204 | flex-direction: row;
205 | align-items: center;
206 | justify-content: space-between;
207 | gap: var(--UI-Spacing-spacing-xs);
208 | padding: 0;
209 | min-width: 0;
210 | }
211 |
212 | .bespoke .realtime-stat-label {
213 | font-size: var(--Fonts-Body-Default-xs);
214 | color: var(--Colors-Text-Body-Medium);
215 | text-transform: uppercase;
216 | letter-spacing: 0.5px;
217 | font-weight: 500;
218 | white-space: nowrap;
219 | flex-shrink: 0;
220 | }
221 |
222 | .bespoke .realtime-stat-value {
223 | font-size: var(--Fonts-Headlines-sm);
224 | font-weight: 600;
225 | color: var(--Colors-Primary-Default);
226 | font-family: var(--heading-family);
227 | text-align: right;
228 | flex-shrink: 0;
229 | min-width: 60px;
230 | font-variant-numeric: tabular-nums;
231 | }
232 |
233 | .bespoke .typing-text-container {
234 | flex: 1 1 auto;
235 | position: relative;
236 | overflow: hidden; /* Remove scrolling - content must fit */
237 | padding: var(--UI-Spacing-spacing-mxl);
238 | background: transparent;
239 | font-family: var(--body-family);
240 | font-size: var(--Fonts-Body-Default-xxxl);
241 | line-height: 140%;
242 | cursor: text;
243 | max-width: 900px;
244 | width: 100%;
245 | margin: var(--UI-Spacing-spacing-lg) auto 0;
246 | min-height: 0; /* Allow flexbox to shrink */
247 | display: flex;
248 | flex-direction: column;
249 | }
250 |
251 | .bespoke .typing-text {
252 | white-space: pre-wrap;
253 | word-wrap: break-word;
254 | user-select: none;
255 | text-align: left;
256 | width: 100%;
257 | overflow: hidden; /* Prevent text from causing scroll */
258 | flex: 1 1 auto;
259 | min-height: 0;
260 | }
261 |
262 | .bespoke .typing-text .char-pending {
263 | color: var(--Colors-Text-Body-Light);
264 | }
265 |
266 | .bespoke .typing-text .char-correct {
267 | color: var(--Colors-Text-Body-Strongest);
268 | }
269 |
270 | .bespoke .typing-text .char-incorrect {
271 | color: var(--Colors-Alert-Error-Default);
272 | }
273 |
274 | .bespoke .typing-text .char-incorrect.char-space {
275 | background-color: color-mix(in srgb, var(--Colors-Alert-Error-Default) 30%, transparent);
276 | border-radius: 2px;
277 | padding: 0 2px;
278 | }
279 |
280 | .bespoke .typing-text .cursor-position {
281 | position: relative;
282 | }
283 |
284 | .bespoke .typing-text .cursor-position::before {
285 | content: '';
286 | position: absolute;
287 | left: -1px;
288 | top: 0;
289 | bottom: 0;
290 | width: 2px;
291 | background-color: var(--Colors-Primary-Default);
292 | animation: blink 1s step-end infinite;
293 | pointer-events: none;
294 | }
295 |
296 | @keyframes blink {
297 | 0%, 50% {
298 | opacity: 1;
299 | }
300 | 51%, 100% {
301 | opacity: 0;
302 | }
303 | }
304 |
305 | .bespoke .hidden-input {
306 | position: absolute;
307 | opacity: 0;
308 | pointer-events: none;
309 | width: 0;
310 | height: 0;
311 | border: none;
312 | padding: 0;
313 | margin: 0;
314 | }
315 |
316 | .bespoke .restart-button-container {
317 | position: fixed;
318 | bottom: var(--UI-Spacing-spacing-mxl);
319 | left: var(--UI-Spacing-spacing-mxl);
320 | z-index: 1000;
321 | }
322 |
323 | .bespoke .restart-button-container button {
324 | display: flex;
325 | align-items: center;
326 | justify-content: center;
327 | width: 40px;
328 | height: 40px;
329 | padding: 0;
330 | border-radius: var(--UI-Radius-radius-s);
331 | }
332 |
333 | .bespoke .restart-button-container button:active,
334 | .bespoke .restart-button-container button.active {
335 | box-shadow: none;
336 | border-bottom: none;
337 | color: var(--Colors-Buttons-Text-Primary-Hover);
338 | }
339 |
340 | .bespoke .restart-button-container button:focus,
341 | .bespoke .restart-button-container button.focus {
342 | box-shadow: none;
343 | border-bottom: none;
344 | outline: none;
345 | color: var(--Colors-Buttons-Text-Primary-Hover);
346 | }
347 |
348 | .bespoke .restart-button-container button svg {
349 | width: 20px;
350 | height: 20px;
351 | }
352 |
353 | .bespoke .completion-screen {
354 | display: none;
355 | position: fixed;
356 | top: 50%;
357 | left: 50%;
358 | transform: translate(-50%, -50%);
359 | flex-direction: column;
360 | align-items: center;
361 | justify-content: center;
362 | gap: var(--UI-Spacing-spacing-xl);
363 | padding: var(--UI-Spacing-spacing-xxxl);
364 | background: var(--Colors-Box-Background);
365 | border-radius: var(--UI-Radius-radius-m);
366 | border: 1px solid var(--Colors-Stroke-Default);
367 | text-align: center;
368 | z-index: 1000;
369 | min-width: 400px;
370 | max-width: 90vw;
371 | max-height: 90vh;
372 | overflow-y: auto;
373 | box-shadow: 0 3px 2px 0 var(--Colors-Shadow-Card);
374 | }
375 |
376 | .bespoke .completion-screen h2 {
377 | font-size: var(--Fonts-Headlines-md);
378 | margin: 0;
379 | color: var(--Colors-Text-Body-Strongest);
380 | font-family: var(--heading-family);
381 | font-weight: 500;
382 | }
383 |
384 | .bespoke .completion-screen p {
385 | font-size: var(--Fonts-Body-Default-lg);
386 | color: var(--Colors-Text-Body-Medium);
387 | margin: 0;
388 | }
389 |
390 | /* Stats Dashboard Styles */
391 | .bespoke .stats-dashboard {
392 | display: none;
393 | position: fixed;
394 | top: 50%;
395 | left: 50%;
396 | transform: translate(-50%, -50%);
397 | flex-direction: column;
398 | align-items: center;
399 | justify-content: flex-start;
400 | gap: var(--UI-Spacing-spacing-xl);
401 | padding: var(--UI-Spacing-spacing-xl);
402 | background: var(--Colors-Backgrounds-Main-Default);
403 | text-align: center;
404 | z-index: 1000;
405 | min-width: 600px;
406 | max-width: 90vw;
407 | max-height: 90vh;
408 | overflow-y: auto;
409 | }
410 |
411 | .bespoke .stats-dashboard-header {
412 | width: 100%;
413 | margin-bottom: var(--UI-Spacing-spacing-lg);
414 | }
415 |
416 | .bespoke .stats-dashboard-header h2 {
417 | font-size: var(--Fonts-Headlines-xl);
418 | margin: 0 0 var(--UI-Spacing-spacing-xs) 0;
419 | color: var(--Colors-Text-Body-Strongest);
420 | font-family: var(--heading-family);
421 | font-weight: 500;
422 | }
423 |
424 | .bespoke .stats-subtitle {
425 | font-size: var(--Fonts-Body-Default-md);
426 | color: var(--Colors-Text-Body-Medium);
427 | margin: 0;
428 | }
429 |
430 | .bespoke .stats-grid {
431 | display: grid;
432 | grid-template-columns: repeat(5, 1fr);
433 | gap: var(--UI-Spacing-spacing-mxl);
434 | width: 100%;
435 | margin: var(--UI-Spacing-spacing-xl) 0;
436 | }
437 |
438 | .bespoke .stat-card {
439 | display: flex;
440 | flex-direction: column;
441 | align-items: center;
442 | justify-content: center;
443 | padding: var(--UI-Spacing-spacing-mxl);
444 | background: var(--Colors-Box-Background-Secondary);
445 | border: 1px solid var(--Colors-Stroke-Default);
446 | border-radius: var(--UI-Radius-radius-m);
447 | transition: transform 0.2s ease, box-shadow 0.2s ease;
448 | }
449 |
450 | .bespoke .stat-card:hover {
451 | transform: translateY(-2px);
452 | box-shadow: 0 4px 8px 0 var(--Colors-Shadow-Card);
453 | }
454 |
455 | .bespoke .stat-value-container {
456 | display: flex;
457 | align-items: center;
458 | justify-content: center;
459 | margin-bottom: var(--UI-Spacing-spacing-xs);
460 | }
461 |
462 | .bespoke .stat-value {
463 | font-size: var(--Fonts-Headlines-sm);
464 | font-weight: 600;
465 | color: var(--Colors-Primary-Default);
466 | font-family: var(--heading-family);
467 | }
468 |
469 | .bespoke .stat-label {
470 | font-size: var(--Fonts-Body-Default-sm);
471 | color: var(--Colors-Text-Body-Medium);
472 | text-transform: uppercase;
473 | letter-spacing: 0.5px;
474 | }
475 |
476 | .bespoke .stats-dashboard-footer {
477 | width: 100%;
478 | margin-top: var(--UI-Spacing-spacing-lg);
479 | }
480 |
481 | /* Mini Keyboard Styles */
482 | .bespoke .keyboard-container {
483 | display: none;
484 | width: 100%;
485 | padding: var(--UI-Spacing-spacing-lg);
486 | background: transparent;
487 | overflow: hidden; /* Prevent keyboard from causing scroll */
488 | }
489 |
490 | .bespoke .keyboard-container.visible {
491 | display: block;
492 | }
493 |
494 | .bespoke .keyboard-container .keyboard {
495 | margin: 0 auto; /* Center the keyboard */
496 | }
497 |
498 | .bespoke .keyboard-container.visible {
499 | display: block;
500 | }
501 |
502 | .bespoke .keyboard {
503 | display: flex;
504 | flex-direction: column;
505 | gap: var(--UI-Spacing-spacing-xs);
506 | font-family: var(--body-family);
507 | font-size: 12px;
508 | user-select: none;
509 | }
510 |
511 | .bespoke .keyboard-row {
512 | display: flex;
513 | justify-content: center;
514 | gap: var(--UI-Spacing-spacing-xs);
515 | }
516 |
517 | .bespoke .keyboard-key {
518 | display: flex;
519 | align-items: center;
520 | justify-content: center;
521 | min-width: 28px;
522 | height: 32px;
523 | padding: 0 var(--UI-Spacing-spacing-sm);
524 | background: var(--Colors-Box-Background-Secondary);
525 | border: 1px solid var(--Colors-Stroke-Default);
526 | border-radius: var(--UI-Radius-radius-s);
527 | color: var(--Colors-Text-Body-Strongest);
528 | font-weight: 500;
529 | transition: all 0.15s ease;
530 | box-shadow: 0 1px 2px 0 var(--Colors-Shadow-Card);
531 | }
532 |
533 | .bespoke .keyboard-key.space {
534 | min-width: 200px;
535 | }
536 |
537 | .bespoke .keyboard-key.enter {
538 | min-width: 60px;
539 | }
540 |
541 | .bespoke .keyboard-key.shift {
542 | min-width: 70px;
543 | }
544 |
545 | .bespoke .keyboard-key.backspace {
546 | min-width: 70px;
547 | }
548 |
549 | .bespoke .keyboard-key.tab {
550 | min-width: 50px;
551 | }
552 |
553 | .bespoke .keyboard-key.caps {
554 | min-width: 60px;
555 | }
556 |
557 | .bespoke .keyboard-key.active {
558 | background: color-mix(in srgb, var(--Colors-Primary-Default) 30%, transparent);
559 | border-color: var(--Colors-Primary-Default);
560 | transform: scale(0.95);
561 | }
562 |
563 | .bespoke .keyboard-key.active-error {
564 | background: color-mix(in srgb, var(--Colors-Alert-Error-Default) 30%, transparent);
565 | border-color: var(--Colors-Alert-Error-Default);
566 | transform: scale(0.95);
567 | }
568 |
569 | .bespoke .keyboard-key.unavailable {
570 | background: var(--Colors-Box-Background);
571 | border-color: var(--Colors-Stroke-Light);
572 | color: var(--Colors-Text-Body-Light);
573 | opacity: 0.5;
574 | cursor: not-allowed;
575 | }
576 |
577 | /* Racing Track Responsive Styles */
578 | @media (max-width: 1024px) {
579 | .bespoke .racing-track {
580 | height: 250px;
581 | }
582 |
583 | .bespoke .racing-track-lane {
584 | height: 62.5px;
585 | }
586 |
587 | .bespoke .racing-track-lane[data-lane="1"] {
588 | top: 62.5px;
589 | }
590 |
591 | .bespoke .racing-track-lane[data-lane="2"] {
592 | top: 125px;
593 | }
594 |
595 | .bespoke .racing-track-lane[data-lane="3"] {
596 | top: 187.5px;
597 | }
598 |
599 | .bespoke .racing-text-overlay {
600 | height: 62.5px; /* Match lane 0 height */
601 | }
602 |
603 | .bespoke .racing-typing-text {
604 | font-size: var(--Fonts-Body-Default-md);
605 | }
606 | }
607 |
608 | @media (max-width: 768px) {
609 | .bespoke .racing-track {
610 | height: 200px;
611 | }
612 |
613 | .bespoke .racing-track-lane {
614 | height: 50px;
615 | }
616 |
617 | .bespoke .racing-track-lane[data-lane="1"] {
618 | top: 50px;
619 | }
620 |
621 | .bespoke .racing-track-lane[data-lane="2"] {
622 | top: 100px;
623 | }
624 |
625 | .bespoke .racing-track-lane[data-lane="3"] {
626 | top: 150px;
627 | }
628 |
629 | .bespoke .racing-text-overlay {
630 | height: 50px; /* Match lane 0 height */
631 | }
632 |
633 | .bespoke .racing-car svg {
634 | width: 32px;
635 | height: 32px;
636 | }
637 |
638 | .bespoke .racing-typing-text {
639 | font-size: var(--Fonts-Body-Default-sm);
640 | }
641 | }
642 |
643 | @media (max-width: 480px) {
644 | .bespoke .racing-track {
645 | height: 180px;
646 | }
647 |
648 | .bespoke .racing-track-lane {
649 | height: 45px;
650 | }
651 |
652 | .bespoke .racing-track-lane[data-lane="1"] {
653 | top: 45px;
654 | }
655 |
656 | .bespoke .racing-track-lane[data-lane="2"] {
657 | top: 90px;
658 | }
659 |
660 | .bespoke .racing-track-lane[data-lane="3"] {
661 | top: 135px;
662 | }
663 |
664 | .bespoke .racing-text-overlay {
665 | height: 45px; /* Match lane 0 height */
666 | }
667 |
668 | .bespoke .racing-car svg {
669 | width: 28px;
670 | height: 28px;
671 | }
672 |
673 | .bespoke .racing-typing-text {
674 | font-size: var(--Fonts-Body-Default-xs);
675 | }
676 | }
677 |
678 | /* Responsive Design */
679 | @media (max-width: 1024px) {
680 | .bespoke .keyboard-stats-wrapper {
681 | margin: var(--UI-Spacing-spacing-lg) auto 0;
682 | }
683 |
684 | .bespoke .realtime-stats-container {
685 | gap: 2px;
686 | padding: var(--UI-Spacing-spacing-sm);
687 | min-width: 130px;
688 | }
689 |
690 | .bespoke .realtime-stat-item {
691 | padding: 0;
692 | gap: var(--UI-Spacing-spacing-sm);
693 | }
694 |
695 | .bespoke .realtime-stat-label {
696 | font-size: var(--Fonts-Body-Default-xxs);
697 | }
698 |
699 | .bespoke .realtime-stat-value {
700 | font-size: var(--Fonts-Headlines-xs);
701 | }
702 |
703 | .bespoke .typing-text-container {
704 | font-size: var(--Fonts-Body-Default-xxl);
705 | padding: var(--UI-Spacing-spacing-xl);
706 | margin: var(--UI-Spacing-spacing-md) auto 0;
707 | }
708 |
709 | .bespoke .keyboard-container {
710 | padding: var(--UI-Spacing-spacing-md);
711 | }
712 |
713 | .bespoke .keyboard {
714 | font-size: 11px;
715 | }
716 |
717 | .bespoke .keyboard-key {
718 | min-width: 24px;
719 | height: 28px;
720 | padding: 0 var(--UI-Spacing-spacing-xs);
721 | }
722 |
723 | .bespoke .keyboard-key.space {
724 | min-width: 150px;
725 | }
726 |
727 | .bespoke .keyboard-key.enter {
728 | min-width: 50px;
729 | }
730 |
731 | .bespoke .keyboard-key.shift {
732 | min-width: 60px;
733 | }
734 |
735 | .bespoke .keyboard-key.backspace {
736 | min-width: 60px;
737 | }
738 |
739 | .bespoke .keyboard-key.tab {
740 | min-width: 40px;
741 | }
742 |
743 | .bespoke .keyboard-key.caps {
744 | min-width: 50px;
745 | }
746 | }
747 |
748 | @media (max-width: 768px) {
749 | .bespoke .keyboard-stats-wrapper {
750 | margin: var(--UI-Spacing-spacing-md) auto 0;
751 | }
752 |
753 | .bespoke .realtime-stats-container {
754 | position: static;
755 | width: 100%;
756 | max-width: 100%;
757 | min-width: auto;
758 | gap: 2px;
759 | padding: var(--UI-Spacing-spacing-sm);
760 | margin-bottom: var(--UI-Spacing-spacing-md);
761 | }
762 |
763 | .bespoke .realtime-stat-item {
764 | padding: 0;
765 | gap: var(--UI-Spacing-spacing-sm);
766 | }
767 |
768 | .bespoke .keyboard-container {
769 | width: 100%;
770 | }
771 |
772 | .bespoke .typing-container {
773 | padding: var(--UI-Spacing-spacing-md);
774 | }
775 |
776 | .bespoke .typing-text-container {
777 | font-size: var(--Fonts-Body-Default-xl);
778 | padding: var(--UI-Spacing-spacing-lg);
779 | margin: var(--UI-Spacing-spacing-sm) auto 0;
780 | }
781 |
782 | .bespoke .completion-screen {
783 | min-width: 90vw;
784 | max-width: 90vw;
785 | padding: var(--UI-Spacing-spacing-xl);
786 | gap: var(--UI-Spacing-spacing-lg);
787 | }
788 |
789 | .bespoke .completion-screen h2 {
790 | font-size: var(--Fonts-Headlines-sm);
791 | }
792 |
793 | .bespoke .completion-screen p {
794 | font-size: var(--Fonts-Body-Default-md);
795 | }
796 |
797 | .bespoke .stats-dashboard {
798 | min-width: 90vw;
799 | max-width: 90vw;
800 | padding: var(--UI-Spacing-spacing-xl);
801 | gap: var(--UI-Spacing-spacing-lg);
802 | }
803 |
804 | .bespoke .stats-dashboard-header h2 {
805 | font-size: var(--Fonts-Headlines-lg);
806 | }
807 |
808 | .bespoke .stats-subtitle {
809 | font-size: var(--Fonts-Body-Default-sm);
810 | }
811 |
812 | .bespoke .stats-grid {
813 | grid-template-columns: repeat(5, 1fr);
814 | gap: var(--UI-Spacing-spacing-m);
815 | }
816 |
817 | .bespoke .stat-card {
818 | padding: var(--UI-Spacing-spacing-m);
819 | }
820 |
821 | .bespoke .stat-value {
822 | font-size: var(--Fonts-Headlines-xs);
823 | }
824 |
825 | .bespoke .stat-label {
826 | font-size: var(--Fonts-Body-Default-xs);
827 | }
828 |
829 | .bespoke .keyboard-container {
830 | padding: var(--UI-Spacing-spacing-sm);
831 | margin: var(--UI-Spacing-spacing-md) auto 0;
832 | }
833 |
834 | .bespoke .keyboard {
835 | font-size: 10px;
836 | gap: 2px;
837 | }
838 |
839 | .bespoke .keyboard-row {
840 | gap: 2px;
841 | }
842 |
843 | .bespoke .keyboard-key {
844 | min-width: 20px;
845 | height: 24px;
846 | padding: 0 4px;
847 | font-size: 10px;
848 | }
849 |
850 | .bespoke .keyboard-key.space {
851 | min-width: 120px;
852 | }
853 |
854 | .bespoke .keyboard-key.enter {
855 | min-width: 40px;
856 | }
857 |
858 | .bespoke .keyboard-key.shift {
859 | min-width: 50px;
860 | }
861 |
862 | .bespoke .keyboard-key.backspace {
863 | min-width: 50px;
864 | }
865 |
866 | .bespoke .keyboard-key.tab {
867 | min-width: 35px;
868 | }
869 |
870 | .bespoke .keyboard-key.caps {
871 | min-width: 40px;
872 | }
873 |
874 | .bespoke .restart-button-container {
875 | bottom: var(--UI-Spacing-spacing-md);
876 | left: var(--UI-Spacing-spacing-md);
877 | }
878 |
879 | .bespoke .restart-button-container button {
880 | width: 36px;
881 | height: 36px;
882 | }
883 |
884 | .bespoke .restart-button-container button svg {
885 | width: 18px;
886 | height: 18px;
887 | }
888 | }
889 |
890 | /* Meteorite Rain Game Styles */
891 | .bespoke .meteorite-rain-container {
892 | flex: 1 1 auto;
893 | position: relative;
894 | width: 100%;
895 | height: 100%;
896 | display: flex;
897 | flex-direction: column;
898 | min-height: 0;
899 | background: #000232;
900 | overflow: hidden;
901 | }
902 |
903 | .bespoke .meteorite-rain-game {
904 | position: relative;
905 | width: 100%;
906 | height: 100%;
907 | display: flex;
908 | flex-direction: column;
909 | }
910 |
911 | .bespoke .meteorite-rain-ui {
912 | position: absolute;
913 | top: var(--UI-Spacing-spacing-xl);
914 | left: var(--UI-Spacing-spacing-xl);
915 | right: var(--UI-Spacing-spacing-xl);
916 | z-index: 100;
917 | display: flex;
918 | flex-direction: row;
919 | justify-content: space-between;
920 | align-items: center;
921 | gap: var(--UI-Spacing-spacing-xl);
922 | pointer-events: none;
923 | }
924 |
925 | .bespoke .meteorite-score {
926 | font-size: var(--Fonts-Headlines-xl);
927 | font-weight: 600;
928 | color: #FFFFFF;
929 | font-family: var(--heading-family);
930 | line-height: 1;
931 | }
932 |
933 | .bespoke .meteorite-lives {
934 | display: flex;
935 | flex-direction: row;
936 | gap: var(--UI-Spacing-spacing-sm);
937 | align-items: center;
938 | }
939 |
940 | .bespoke .meteorite-heart {
941 | width: 20px;
942 | height: 20px;
943 | background-image: url('./assets/heart.svg');
944 | background-size: contain;
945 | background-repeat: no-repeat;
946 | background-position: center;
947 | display: inline-block;
948 | }
949 |
950 | .bespoke .meteorite-start-message {
951 | position: absolute;
952 | top: 50%;
953 | left: 50%;
954 | transform: translate(-50%, -50%);
955 | z-index: 200;
956 | font-size: var(--Fonts-Headlines-lg);
957 | font-weight: 600;
958 | color: #FFFFFF;
959 | font-family: var(--heading-family);
960 | text-align: center;
961 | pointer-events: none;
962 | display: flex;
963 | align-items: center;
964 | justify-content: center;
965 | }
966 |
967 | .bespoke .meteorite-rain-play-area {
968 | flex: 1 1 auto;
969 | position: relative;
970 | width: 100%;
971 | min-height: 0;
972 | overflow: hidden;
973 | }
974 |
975 | .bespoke .meteorite {
976 | position: absolute;
977 | display: flex;
978 | flex-direction: column;
979 | align-items: center;
980 | gap: var(--UI-Spacing-spacing-xs);
981 | z-index: 10;
982 | }
983 |
984 | .bespoke .meteorite-circle {
985 | width: 40px;
986 | height: 40px;
987 | background-image: url('./assets/meteor.svg');
988 | background-size: contain;
989 | background-repeat: no-repeat;
990 | background-position: center;
991 | }
992 |
993 | .bespoke .meteorite-word {
994 | font-size: var(--Fonts-Body-Default-sm);
995 | font-weight: 600;
996 | color: #FFFFFF;
997 | white-space: nowrap;
998 | pointer-events: none;
999 | text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
1000 | }
1001 |
1002 | .bespoke .meteorite-ground {
1003 | position: absolute;
1004 | bottom: 0;
1005 | left: 0;
1006 | right: 0;
1007 | height: 20px;
1008 | background: #C4C4C4;
1009 | z-index: 5;
1010 | box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.2);
1011 | }
1012 |
1013 | .bespoke .meteorite-typing-input-container {
1014 | position: absolute;
1015 | bottom: 30px;
1016 | left: 50%;
1017 | transform: translateX(-50%);
1018 | z-index: 50;
1019 | display: flex;
1020 | flex-direction: column;
1021 | align-items: center;
1022 | min-width: 200px;
1023 | max-width: 300px;
1024 | width: auto;
1025 | }
1026 |
1027 | .bespoke .meteorite-typing-input-container .input {
1028 | width: 100%;
1029 | }
1030 |
1031 | @media (max-width: 480px) {
1032 | .bespoke .keyboard-stats-wrapper {
1033 | margin: var(--UI-Spacing-spacing-sm) auto 0;
1034 | }
1035 |
1036 | .bespoke .realtime-stats-container {
1037 | position: static;
1038 | width: 100%;
1039 | max-width: 100%;
1040 | min-width: auto;
1041 | gap: 2px;
1042 | padding: var(--UI-Spacing-spacing-xs);
1043 | margin-bottom: var(--UI-Spacing-spacing-sm);
1044 | }
1045 |
1046 | .bespoke .realtime-stat-item {
1047 | padding: 0;
1048 | gap: var(--UI-Spacing-spacing-xs);
1049 | }
1050 |
1051 | .bespoke .typing-container {
1052 | padding: var(--UI-Spacing-spacing-sm);
1053 | }
1054 |
1055 | .bespoke .typing-text-container {
1056 | font-size: var(--Fonts-Body-Default-lg);
1057 | padding: var(--UI-Spacing-spacing-md);
1058 | margin: var(--UI-Spacing-spacing-xs) auto 0;
1059 | }
1060 |
1061 | .bespoke .completion-screen {
1062 | padding: var(--UI-Spacing-spacing-lg);
1063 | gap: var(--UI-Spacing-spacing-md);
1064 | }
1065 |
1066 | .bespoke .stats-dashboard {
1067 | padding: var(--UI-Spacing-spacing-lg);
1068 | gap: var(--UI-Spacing-spacing-md);
1069 | }
1070 |
1071 | .bespoke .stats-grid {
1072 | grid-template-columns: repeat(5, 1fr);
1073 | gap: var(--UI-Spacing-spacing-s);
1074 | }
1075 |
1076 | .bespoke .stat-card {
1077 | padding: var(--UI-Spacing-spacing-s);
1078 | }
1079 |
1080 | .bespoke .stat-value {
1081 | font-size: 0.875rem;
1082 | }
1083 |
1084 | .bespoke .keyboard-container {
1085 | padding: var(--UI-Spacing-spacing-xs);
1086 | margin: var(--UI-Spacing-spacing-sm) auto 0;
1087 | }
1088 |
1089 | .bespoke .keyboard {
1090 | font-size: 8px;
1091 | gap: 1px;
1092 | }
1093 |
1094 | .bespoke .keyboard-row {
1095 | gap: 1px;
1096 | }
1097 |
1098 | .bespoke .keyboard-key {
1099 | min-width: 16px;
1100 | height: 20px;
1101 | padding: 0 2px;
1102 | font-size: 8px;
1103 | }
1104 |
1105 | .bespoke .keyboard-key.space {
1106 | min-width: 100px;
1107 | }
1108 |
1109 | .bespoke .keyboard-key.enter {
1110 | min-width: 35px;
1111 | }
1112 |
1113 | .bespoke .keyboard-key.shift {
1114 | min-width: 40px;
1115 | }
1116 |
1117 | .bespoke .keyboard-key.backspace {
1118 | min-width: 40px;
1119 | }
1120 |
1121 | .bespoke .keyboard-key.tab {
1122 | min-width: 30px;
1123 | }
1124 |
1125 | .bespoke .keyboard-key.caps {
1126 | min-width: 35px;
1127 | }
1128 | }
1129 |
--------------------------------------------------------------------------------
/client/typing-simulator.js:
--------------------------------------------------------------------------------
1 | // typing-simulator.js
2 | (function() {
3 | let originalText = '';
4 | let typedText = '';
5 | let textContainer = null;
6 | let hiddenInput = null;
7 | let completionScreen = null;
8 | let statsDashboard = null;
9 | let restartButton = null;
10 | let startOverButton = null;
11 | let statsStartOverButton = null;
12 | let keyboardContainer = null;
13 | let realtimeStatsContainer = null;
14 | let config = { keyboard: true, availableKeys: [], showStats: false, realTimeStats: [], gameType: 'classic' };
15 |
16 | // Normalized set of available keys (for fast lookup)
17 | let availableKeysSet = new Set();
18 |
19 | // Character states: 'pending', 'correct', 'incorrect'
20 | const charStates = [];
21 |
22 | // Statistics tracking
23 | let startTime = null;
24 | let totalErrors = 0;
25 | let totalInputs = 0;
26 |
27 | // Keyboard state
28 | let keyboardEnabled = false;
29 | let activeKeyElement = null;
30 | let activeKeyTimeout = null;
31 |
32 | // Real-time stats update interval
33 | let realtimeStatsInterval = null;
34 |
35 | // Game manager - handles different game types
36 | let currentGame = null;
37 | let gameUpdateInterval = null;
38 | let gameAnimationFrame = null;
39 |
40 | function setStatus(msg) {
41 | // Status element removed - function kept for compatibility but does nothing
42 | }
43 |
44 | // Load configuration
45 | async function loadConfig() {
46 | try {
47 | const response = await fetch('./config.json');
48 | if (!response.ok) {
49 | console.warn('Config file not found, using defaults');
50 | return;
51 | }
52 | config = await response.json();
53 |
54 | // Normalize available keys to lowercase for fast lookup
55 | // Empty array means all keys are available
56 | if (config.availableKeys && Array.isArray(config.availableKeys) && config.availableKeys.length > 0) {
57 | availableKeysSet = new Set(config.availableKeys.map(key => key.toLowerCase()));
58 | } else {
59 | availableKeysSet = new Set(); // Empty set means all keys available
60 | }
61 |
62 | // Set default game type if not specified
63 | if (!config.gameType) {
64 | config.gameType = 'classic';
65 | }
66 | } catch (error) {
67 | console.warn('Error loading config:', error);
68 | }
69 | }
70 |
71 | // ==================== GAME MANAGER ====================
72 |
73 | // Racing Game Implementation
74 | class RacingGame {
75 | constructor() {
76 | this.trackContainer = document.getElementById('racing-track-container');
77 | this.typingTextElement = document.getElementById('racing-typing-text');
78 | this.playerCar = document.getElementById('car-player');
79 | this.opponentCars = [
80 | document.getElementById('car-opponent-1'),
81 | document.getElementById('car-opponent-2'),
82 | document.getElementById('car-opponent-3')
83 | ];
84 | this.finishLine = document.getElementById('racing-finish-line');
85 | this.trackElement = this.trackContainer ? this.trackContainer.querySelector('.racing-track') : null;
86 |
87 | this.opponentPositions = [0, 0, 0];
88 | this.opponentSpeeds = config.racing?.opponentSpeeds || [0.3, 0.4, 0.5];
89 | // Convert speeds from pixels per frame (at 60fps) to pixels per second
90 | // Original speeds: 0.3, 0.4, 0.5 px/frame at 60fps = 18, 24, 30 px/s
91 | this.opponentSpeedsPxPerSec = this.opponentSpeeds.map(speed => speed * 60);
92 | // Current speeds with randomness (initialized to base speeds)
93 | this.currentOpponentSpeeds = [...this.opponentSpeedsPxPerSec];
94 | this.speedUpdateTimer = 0; // Timer for speed updates (in milliseconds)
95 | this.speedUpdateInterval = 1500 + Math.random() * 1000; // Update speed every 1.5-2.5 seconds
96 | this.lastFrameTime = null; // For delta time calculation
97 | this.trackWidth = 0;
98 | this.finishLineTextPosition = 0; // Position in text coordinates
99 | this.isFinished = false;
100 | this.playerWon = null; // null = not finished, true = player won, false = player lost
101 | }
102 |
103 | initialize() {
104 | if (!this.trackContainer || !this.trackElement) return;
105 |
106 | // Show racing track, hide classic view
107 | this.trackContainer.style.display = 'block';
108 | const classicContainer = document.getElementById('classic-typing-container');
109 | if (classicContainer) {
110 | classicContainer.style.display = 'none';
111 | }
112 |
113 | // Calculate track dimensions
114 | this.updateTrackDimensions();
115 |
116 | // Reset positions
117 | this.reset();
118 | }
119 |
120 | updateTrackDimensions() {
121 | if (!this.trackElement) return;
122 | this.trackWidth = this.trackElement.offsetWidth;
123 | // Finish line position will be calculated dynamically based on text end
124 | }
125 |
126 | reset() {
127 | this.opponentPositions = [0, 0, 0];
128 | this.isFinished = false;
129 | this.finishLineTextPosition = 0;
130 | this.playerWon = null; // Reset win/loss status
131 | // Reset speeds to base speeds (in px/s)
132 | this.currentOpponentSpeeds = [...this.opponentSpeedsPxPerSec];
133 | this.speedUpdateTimer = 0;
134 | this.speedUpdateInterval = 1500 + Math.random() * 1000; // Reset update interval
135 | this.lastFrameTime = null; // Reset frame time
136 |
137 | if (this.playerCar) {
138 | this.playerCar.style.left = '20px';
139 | }
140 |
141 | this.opponentCars.forEach((car, index) => {
142 | if (car) {
143 | car.style.left = '20px';
144 | }
145 | this.opponentPositions[index] = 0;
146 | });
147 |
148 | // Update finish line position after reset
149 | setTimeout(() => {
150 | this.updateFinishLinePosition();
151 | }, 0);
152 | }
153 |
154 | updateOpponentSpeeds() {
155 | // Update speeds with small random variations
156 | // Variations are ±20% of base speed to keep it realistic
157 | this.opponentSpeedsPxPerSec.forEach((baseSpeed, index) => {
158 | const variation = 0.2; // ±20% variation
159 | const randomFactor = 1 + (Math.random() * 2 - 1) * variation; // Random between 0.8 and 1.2
160 | this.currentOpponentSpeeds[index] = baseSpeed * randomFactor;
161 | });
162 |
163 | // Reset timer and set new random interval
164 | this.speedUpdateTimer = 0;
165 | this.speedUpdateInterval = 1500 + Math.random() * 1000; // 1.5-2.5 seconds
166 | }
167 |
168 | updatePlayerPosition() {
169 | if (!this.playerCar || this.isFinished || !this.typingTextElement) return;
170 |
171 | // Get the cursor element position
172 | const cursorElement = this.typingTextElement.querySelector('.cursor-position');
173 | if (!cursorElement) {
174 | // If no cursor, position at start of text
175 | const firstChar = this.typingTextElement.querySelector('span');
176 | if (firstChar) {
177 | const carWidth = 40;
178 | const position = 70 + firstChar.offsetLeft - carWidth; // Car front at first character
179 | this.playerCar.style.left = `${Math.max(20, position)}px`;
180 | }
181 | return;
182 | }
183 |
184 | // Get the absolute position of the cursor within the text element
185 | const cursorLeft = cursorElement.offsetLeft;
186 |
187 | // Position car so its front (right edge) is at cursor position
188 | const carWidth = 40; // Car SVG width
189 | const position = 70 + cursorLeft - carWidth; // 70px is where text starts, car front at cursor
190 |
191 | this.playerCar.style.left = `${Math.max(20, position)}px`;
192 |
193 | // Update finish line position to end of text
194 | this.updateFinishLinePosition();
195 |
196 | // Check if player crossed finish line
197 | if (cursorLeft >= this.finishLineTextPosition && !this.isFinished) {
198 | this.isFinished = true;
199 | this.playerWon = true; // Player finished first
200 | // Completion will be handled by the main renderText function
201 | }
202 | }
203 |
204 | updateFinishLinePosition() {
205 | if (!this.typingTextElement || !this.finishLine) return;
206 |
207 | // Find the last character span
208 | const allChars = this.typingTextElement.querySelectorAll('span');
209 | if (allChars.length === 0) {
210 | // No text yet - set lanes and track to minimum width
211 | const minWidth = 70; // At least where text starts
212 | const lanes = this.trackElement ? this.trackElement.querySelectorAll('.racing-track-lane') : [];
213 | lanes.forEach(lane => {
214 | lane.style.width = `${minWidth}px`;
215 | });
216 | if (this.trackElement) {
217 | this.trackElement.style.width = `${minWidth}px`;
218 | }
219 | // Finish line is positioned with right: 0 in CSS, so it automatically aligns with track edge
220 | return;
221 | }
222 |
223 | const lastChar = allChars[allChars.length - 1];
224 | const finishLineTextPosition = lastChar.offsetLeft + lastChar.offsetWidth;
225 | this.finishLineTextPosition = finishLineTextPosition;
226 |
227 | // Position finish line at the end of text with a buffer
228 | const buffer = 20; // Buffer space between text end and finish line
229 | const finishLinePosition = 70 + finishLineTextPosition + buffer; // 70px is where text starts
230 |
231 | // Update track lanes to end at finish line
232 | const lanes = this.trackElement ? this.trackElement.querySelectorAll('.racing-track-lane') : [];
233 | lanes.forEach(lane => {
234 | lane.style.width = `${finishLinePosition}px`;
235 | });
236 |
237 | // Update racing-track container width to match finish line
238 | if (this.trackElement) {
239 | this.trackElement.style.width = `${finishLinePosition}px`;
240 | }
241 |
242 | // Finish line is positioned with right: 0 in CSS, so it automatically aligns with track edge
243 | }
244 |
245 | updateOpponents(currentTime) {
246 | if (this.isFinished || !startTime) return;
247 |
248 | // Calculate delta time (time since last frame) in seconds
249 | let deltaTime = 0;
250 | if (this.lastFrameTime !== null) {
251 | deltaTime = (currentTime - this.lastFrameTime) / 1000; // Convert to seconds
252 | // Clamp delta time to prevent large jumps (e.g., when tab regains focus)
253 | deltaTime = Math.min(deltaTime, 0.1); // Max 100ms delta (10fps minimum)
254 | }
255 | this.lastFrameTime = currentTime;
256 |
257 | // Skip update if this is the first frame (no delta time yet)
258 | if (deltaTime === 0) return;
259 |
260 | // Update finish line position first (in case text changed)
261 | this.updateFinishLinePosition();
262 |
263 | // Update speeds periodically with randomness
264 | this.speedUpdateTimer += deltaTime * 1000; // Convert to milliseconds
265 | if (this.speedUpdateTimer >= this.speedUpdateInterval) {
266 | this.updateOpponentSpeeds();
267 | }
268 |
269 | // Get finish line X position - it's at the right edge of the track
270 | // Since finish line uses right: 0, we need to get track width
271 | const trackWidth = this.trackElement ? this.trackElement.offsetWidth : 0;
272 | const finishLineX = trackWidth; // Finish line is at the right edge
273 |
274 | this.opponentCars.forEach((car, index) => {
275 | if (!car) return;
276 |
277 | // Use current speed (with randomness) in pixels per second
278 | // Multiply by deltaTime to get frame-rate independent movement
279 | const speedPxPerSec = this.currentOpponentSpeeds[index] || this.opponentSpeedsPxPerSec[index] || 18;
280 | const movementThisFrame = speedPxPerSec * deltaTime; // pixels this frame
281 | this.opponentPositions[index] += movementThisFrame;
282 |
283 | // Calculate car position
284 | // Car's left edge is at: 20px (start) + opponentPositions[index]
285 | // Car's right edge (front) is at: 20px + opponentPositions[index] + 40px (car width)
286 | const carWidth = 40;
287 | const carLeftPosition = 20 + this.opponentPositions[index];
288 | const carFrontPosition = carLeftPosition + carWidth;
289 |
290 | // Opponents move based on their position, finish line is at end of text
291 | const maxPosition = finishLineX > 0 ? finishLineX - 20 : this.trackWidth - 20;
292 | const position = Math.min(carLeftPosition, maxPosition);
293 | car.style.left = `${position}px`;
294 |
295 | // Check if opponent's front touches finish line (with small buffer for visibility)
296 | const buffer = 10; // Small buffer so it's obvious to user
297 | if (finishLineX > 0 && carFrontPosition >= finishLineX - buffer && !this.isFinished) {
298 | this.isFinished = true;
299 | this.playerWon = false; // Opponent finished first
300 | // Trigger completion screen when opponent wins
301 | console.log('Opponent finished first! Showing completion screen.');
302 | showCompletionScreen();
303 | }
304 | });
305 | }
306 |
307 | renderText(textHtml) {
308 | if (this.typingTextElement) {
309 | this.typingTextElement.innerHTML = textHtml;
310 |
311 | // Wait for DOM to update, then update positions
312 | setTimeout(() => {
313 | this.updatePlayerPosition();
314 | this.updateFinishLinePosition();
315 | }, 0);
316 | }
317 | }
318 |
319 | destroy() {
320 | // Cleanup if needed
321 | this.isFinished = false;
322 | }
323 | }
324 |
325 | // Classic Game Implementation (original behavior)
326 | class ClassicGame {
327 | constructor() {
328 | this.textContainer = document.getElementById('typing-text');
329 | }
330 |
331 | initialize() {
332 | // Show classic view, hide racing track
333 | const classicContainer = document.getElementById('classic-typing-container');
334 | const racingContainer = document.getElementById('racing-track-container');
335 |
336 | if (classicContainer) {
337 | classicContainer.style.display = 'flex';
338 | }
339 | if (racingContainer) {
340 | racingContainer.style.display = 'none';
341 | }
342 | }
343 |
344 | reset() {
345 | // Nothing to reset for classic game
346 | }
347 |
348 | updatePlayerPosition(progress) {
349 | // No visual position update for classic game
350 | }
351 |
352 | updateOpponents() {
353 | // No opponents in classic game
354 | }
355 |
356 | renderText(textHtml) {
357 | if (this.textContainer) {
358 | this.textContainer.innerHTML = textHtml;
359 | }
360 | }
361 |
362 | destroy() {
363 | // Cleanup if needed
364 | }
365 | }
366 |
367 | // Meteorite Rain Game Implementation
368 | class MeteoriteRainGame {
369 | constructor() {
370 | this.container = document.getElementById('meteorite-rain-container');
371 | this.playArea = document.getElementById('meteorite-play-area');
372 | this.scoreElement = document.getElementById('meteorite-score');
373 | this.livesElement = document.getElementById('meteorite-lives');
374 | this.typingInput = document.getElementById('meteorite-typing-input');
375 | this.ground = document.getElementById('meteorite-ground');
376 | this.startMessage = document.getElementById('meteorite-start-message');
377 | this.heartElements = null; // Will be populated when lives element is available
378 |
379 | this.meteorites = [];
380 | this.words = [];
381 | this.score = 0;
382 | this.lives = 3;
383 | this.isFinished = false;
384 | this.hasStarted = false; // Track if game has started
385 | this.currentTypedWord = '';
386 | this.spawnTimer = 0;
387 | this.lastFrameTime = null;
388 | this.gameStartTime = null;
389 | this.pointsPerChar = config.meteoriteRain?.pointsPerChar || 100;
390 | this.spawnIntervalId = null;
391 | this.animationFrame = null;
392 | this.startKeyListener = null; // Store reference to start key listener
393 |
394 | // Difficulty settings
395 | const difficultyConfig = config.meteoriteRain?.difficulty || {};
396 | this.baseSpawnInterval = difficultyConfig.baseSpawnInterval || config.meteoriteRain?.spawnInterval || 2000;
397 | this.minSpawnInterval = difficultyConfig.minSpawnInterval || 500;
398 | this.baseSpeed = difficultyConfig.baseSpeed || config.meteoriteRain?.meteoriteSpeed || 50;
399 | this.maxSpeed = difficultyConfig.maxSpeed || 150;
400 | this.difficultyIncreaseRate = difficultyConfig.difficultyIncreaseRate || 0.1;
401 | }
402 |
403 | initialize() {
404 | if (!this.container || !this.playArea) return;
405 |
406 | // Show meteorite rain container, hide others
407 | this.container.style.display = 'flex';
408 | const classicContainer = document.getElementById('classic-typing-container');
409 | const racingContainer = document.getElementById('racing-track-container');
410 | if (classicContainer) {
411 | classicContainer.style.display = 'none';
412 | }
413 | if (racingContainer) {
414 | racingContainer.style.display = 'none';
415 | }
416 |
417 | // Reset game state
418 | this.reset();
419 | }
420 |
421 | extractWords() {
422 | // Split text into words (split by whitespace and filter empty strings)
423 | this.words = originalText
424 | .split(/\s+/)
425 | .filter(word => word.length > 0)
426 | .map(word => word.toLowerCase().trim());
427 |
428 | // Remove duplicates while preserving order
429 | this.words = [...new Set(this.words)];
430 |
431 | console.log('Extracted words:', this.words);
432 | }
433 |
434 | reset() {
435 | // Extract words if not already extracted
436 | if (this.words.length === 0 && originalText.length > 0) {
437 | this.extractWords();
438 | }
439 |
440 | // Clear all meteorites
441 | this.meteorites.forEach(meteorite => {
442 | if (meteorite.element && meteorite.element.parentNode) {
443 | meteorite.element.parentNode.removeChild(meteorite.element);
444 | }
445 | });
446 | this.meteorites = [];
447 |
448 | // Reset game state
449 | this.score = 0;
450 | this.lives = 3;
451 | this.isFinished = false;
452 | this.hasStarted = false;
453 | this.currentTypedWord = '';
454 | this.spawnTimer = 0;
455 | this.lastFrameTime = null;
456 | this.gameStartTime = null;
457 | this.heartElements = null; // Reset heart elements cache
458 |
459 | // Remove old start key listener if exists
460 | if (this.startKeyListener) {
461 | document.removeEventListener('keydown', this.startKeyListener);
462 | this.startKeyListener = null;
463 | }
464 |
465 | // Show start message
466 | if (this.startMessage) {
467 | this.startMessage.style.display = 'flex';
468 | }
469 |
470 | // Update UI
471 | this.updateScore();
472 | this.updateLives();
473 | this.updateTypingDisplay();
474 |
475 | // Clear intervals and animation frames
476 | if (this.spawnIntervalId) {
477 | clearTimeout(this.spawnIntervalId);
478 | this.spawnIntervalId = null;
479 | }
480 | if (this.animationFrame !== null) {
481 | cancelAnimationFrame(this.animationFrame);
482 | this.animationFrame = null;
483 | }
484 | }
485 |
486 | getCurrentDifficulty() {
487 | if (!this.gameStartTime) return 0;
488 | const elapsedSeconds = (Date.now() - this.gameStartTime) / 1000;
489 | // Difficulty increases linearly over time
490 | return Math.min(elapsedSeconds * this.difficultyIncreaseRate, 1.0);
491 | }
492 |
493 | getRandomSpawnInterval() {
494 | const difficulty = this.getCurrentDifficulty();
495 | // As difficulty increases, spawn interval decreases
496 | // At difficulty 0: baseSpawnInterval
497 | // At difficulty 1: minSpawnInterval
498 | const minInterval = this.minSpawnInterval;
499 | const maxInterval = this.baseSpawnInterval;
500 | const currentMaxInterval = maxInterval - (maxInterval - minInterval) * difficulty;
501 |
502 | // Random interval between 70% and 100% of current max interval
503 | const randomFactor = 0.7 + Math.random() * 0.3;
504 | return Math.max(minInterval, currentMaxInterval * randomFactor);
505 | }
506 |
507 | getCurrentSpeed() {
508 | const difficulty = this.getCurrentDifficulty();
509 | // As difficulty increases, speed increases
510 | // At difficulty 0: baseSpeed
511 | // At difficulty 1: maxSpeed
512 | return this.baseSpeed + (this.maxSpeed - this.baseSpeed) * difficulty;
513 | }
514 |
515 | spawnMeteorite() {
516 | if (this.isFinished || !this.hasStarted || this.words.length === 0) return;
517 |
518 | // Pick a random word
519 | const word = this.words[Math.floor(Math.random() * this.words.length)];
520 |
521 | // Get current speed for this meteorite
522 | const currentSpeed = this.getCurrentSpeed();
523 |
524 | // Create meteorite element
525 | const meteorite = document.createElement('div');
526 | meteorite.className = 'meteorite';
527 |
528 | const circle = document.createElement('div');
529 | circle.className = 'meteorite-circle';
530 |
531 | const wordElement = document.createElement('div');
532 | wordElement.className = 'meteorite-word';
533 | wordElement.textContent = word;
534 |
535 | meteorite.appendChild(circle);
536 | meteorite.appendChild(wordElement);
537 | this.playArea.appendChild(meteorite);
538 |
539 | // Random horizontal position (with padding from edges to avoid score and hearts)
540 | const padding = 100;
541 | const maxX = this.playArea.offsetWidth - padding;
542 | const x = padding + Math.random() * (maxX - padding);
543 |
544 | meteorite.style.left = `${x}px`;
545 | meteorite.style.top = '0px';
546 |
547 | // Store meteorite data with its speed
548 | const meteoriteData = {
549 | element: meteorite,
550 | word: word,
551 | y: 0,
552 | x: x,
553 | speed: currentSpeed // Each meteorite has its own speed based on when it was spawned
554 | };
555 |
556 | this.meteorites.push(meteoriteData);
557 | }
558 |
559 | updateMeteorites(currentTime) {
560 | if (this.isFinished || !this.hasStarted) return;
561 |
562 | // Calculate delta time
563 | let deltaTime = 0;
564 | if (this.lastFrameTime !== null) {
565 | deltaTime = (currentTime - this.lastFrameTime) / 1000;
566 | deltaTime = Math.min(deltaTime, 0.1); // Clamp to prevent large jumps
567 | }
568 | this.lastFrameTime = currentTime;
569 |
570 | if (deltaTime === 0) return;
571 |
572 | const playAreaHeight = this.playArea.offsetHeight;
573 | const groundHeight = this.ground ? this.ground.offsetHeight : 20;
574 |
575 | // Update each meteorite position
576 | this.meteorites.forEach((meteorite, index) => {
577 | // Move meteorite down using its individual speed
578 | const speedPxPerSec = meteorite.speed || this.baseSpeed;
579 | const movementThisFrame = speedPxPerSec * deltaTime;
580 | meteorite.y += movementThisFrame;
581 | meteorite.element.style.top = `${meteorite.y}px`;
582 |
583 | // Check if meteorite hit the ground
584 | const meteoriteBottom = meteorite.y + meteorite.element.offsetHeight;
585 | if (meteoriteBottom >= playAreaHeight - groundHeight) {
586 | // Meteorite hit the ground - lose a life
587 | this.loseLife();
588 | this.destroyMeteorite(index);
589 | }
590 | });
591 |
592 | // Check if game should end
593 | if (this.lives <= 0 && !this.isFinished) {
594 | this.endGame();
595 | }
596 | }
597 |
598 | destroyMeteorite(index) {
599 | const meteorite = this.meteorites[index];
600 | if (meteorite && meteorite.element && meteorite.element.parentNode) {
601 | meteorite.element.parentNode.removeChild(meteorite.element);
602 | }
603 | this.meteorites.splice(index, 1);
604 | }
605 |
606 | checkWordMatch(typedWord) {
607 | if (!typedWord || typedWord.length === 0) return false;
608 |
609 | const typedLower = typedWord.toLowerCase().trim();
610 |
611 | // Find matching meteorite
612 | for (let i = 0; i < this.meteorites.length; i++) {
613 | const meteorite = this.meteorites[i];
614 | if (meteorite.word.toLowerCase() === typedLower) {
615 | // Match! Destroy meteorite and award points
616 | const points = meteorite.word.length * this.pointsPerChar;
617 | this.score += points;
618 | this.updateScore();
619 | this.destroyMeteorite(i);
620 | return true;
621 | }
622 | }
623 | return false;
624 | }
625 |
626 | loseLife() {
627 | if (this.lives > 0) {
628 | this.lives--;
629 | this.updateLives();
630 | }
631 | }
632 |
633 | endGame() {
634 | this.isFinished = true;
635 |
636 | // Stop spawning
637 | if (this.spawnIntervalId) {
638 | clearTimeout(this.spawnIntervalId);
639 | this.spawnIntervalId = null;
640 | }
641 |
642 | // Stop animation
643 | if (this.animationFrame !== null) {
644 | cancelAnimationFrame(this.animationFrame);
645 | this.animationFrame = null;
646 | }
647 |
648 | // Show completion screen with score
649 | showCompletionScreen();
650 | }
651 |
652 | updateScore() {
653 | if (this.scoreElement) {
654 | this.scoreElement.textContent = this.score;
655 | }
656 | }
657 |
658 | updateLives() {
659 | if (!this.livesElement) return;
660 |
661 | // Get or cache heart elements
662 | if (!this.heartElements) {
663 | this.heartElements = this.livesElement.querySelectorAll('.meteorite-heart');
664 | }
665 |
666 | // Show/hide hearts based on current lives
667 | if (this.heartElements) {
668 | this.heartElements.forEach((heart, index) => {
669 | if (index < this.lives) {
670 | heart.style.display = 'inline-block';
671 | } else {
672 | heart.style.display = 'none';
673 | }
674 | });
675 | }
676 | }
677 |
678 | updateTypingDisplay() {
679 | // Input element is managed by handleInput, no need to update display separately
680 | }
681 |
682 | setTypedWord(word) {
683 | this.currentTypedWord = word;
684 | // Input value is managed by the input element itself
685 | }
686 |
687 | beginGame() {
688 | // Mark game as started
689 | this.hasStarted = true;
690 |
691 | // Hide start message
692 | if (this.startMessage) {
693 | this.startMessage.style.display = 'none';
694 | }
695 |
696 | // Remove start key listener
697 | if (this.startKeyListener) {
698 | document.removeEventListener('keydown', this.startKeyListener);
699 | this.startKeyListener = null;
700 | }
701 |
702 | // Set game start time for difficulty calculation
703 | this.gameStartTime = Date.now();
704 |
705 | // Start spawning meteorites with dynamic intervals
706 | this.spawnMeteorite(); // Spawn first one immediately
707 |
708 | // Schedule next spawn with random interval based on difficulty
709 | const scheduleNextSpawn = () => {
710 | if (this.isFinished || !this.hasStarted) return;
711 |
712 | const nextInterval = this.getRandomSpawnInterval();
713 | this.spawnIntervalId = setTimeout(() => {
714 | if (!this.isFinished && this.hasStarted) {
715 | this.spawnMeteorite();
716 | scheduleNextSpawn(); // Schedule next spawn
717 | }
718 | }, nextInterval);
719 | };
720 |
721 | scheduleNextSpawn();
722 |
723 | // Start animation loop
724 | const animate = (currentTime) => {
725 | if (this.isFinished || !this.hasStarted) return;
726 | this.updateMeteorites(currentTime);
727 | this.animationFrame = requestAnimationFrame(animate);
728 | };
729 | this.animationFrame = requestAnimationFrame(animate);
730 |
731 | // Focus the input element
732 | if (this.typingInput) {
733 | setTimeout(() => {
734 | if (this.typingInput) {
735 | this.typingInput.focus();
736 | }
737 | }, 100);
738 | }
739 | }
740 |
741 | startGame() {
742 | // Set up key listener for starting the game
743 | this.setupStartListener();
744 | }
745 |
746 | setupStartListener() {
747 | // Listen for Enter or Space to start the game
748 | this.startKeyListener = (e) => {
749 | if (this.hasStarted || this.isFinished) return;
750 |
751 | if (e.key === 'Enter' || e.key === 'Return' || e.key === ' ' || e.key === 'Space') {
752 | e.preventDefault();
753 | this.beginGame();
754 | }
755 | };
756 |
757 | document.addEventListener('keydown', this.startKeyListener);
758 | }
759 |
760 | renderText(textHtml) {
761 | // Not used in meteorite rain game
762 | }
763 |
764 | destroy() {
765 | // Cleanup
766 | if (this.spawnIntervalId) {
767 | clearTimeout(this.spawnIntervalId);
768 | this.spawnIntervalId = null;
769 | }
770 | if (this.animationFrame !== null) {
771 | cancelAnimationFrame(this.animationFrame);
772 | this.animationFrame = null;
773 | }
774 | if (this.startKeyListener) {
775 | document.removeEventListener('keydown', this.startKeyListener);
776 | this.startKeyListener = null;
777 | }
778 | this.reset();
779 | }
780 |
781 | getScore() {
782 | return this.score;
783 | }
784 | }
785 |
786 | // Game Manager
787 | function initializeGame() {
788 | // Clean up previous game
789 | if (currentGame) {
790 | currentGame.destroy();
791 | if (gameUpdateInterval) {
792 | clearInterval(gameUpdateInterval);
793 | gameUpdateInterval = null;
794 | }
795 | if (gameAnimationFrame !== null) {
796 | cancelAnimationFrame(gameAnimationFrame);
797 | gameAnimationFrame = null;
798 | }
799 | }
800 |
801 | // Initialize based on game type
802 | const gameType = config.gameType || 'classic';
803 |
804 | if (gameType === 'racing') {
805 | currentGame = new RacingGame();
806 | } else if (gameType === 'meteoriteRain') {
807 | currentGame = new MeteoriteRainGame();
808 | } else {
809 | currentGame = new ClassicGame();
810 | }
811 |
812 | currentGame.initialize();
813 |
814 | // Start game update loop for racing using requestAnimationFrame
815 | if (gameType === 'racing' && currentGame instanceof RacingGame) {
816 | function animate(currentTime) {
817 | if (currentGame && currentGame.updateOpponents) {
818 | currentGame.updateOpponents(currentTime);
819 | }
820 | // Continue animation loop
821 | gameAnimationFrame = requestAnimationFrame(animate);
822 | }
823 | // Start the animation loop
824 | gameAnimationFrame = requestAnimationFrame(animate);
825 | }
826 |
827 | // Start meteorite rain game
828 | if (gameType === 'meteoriteRain' && currentGame instanceof MeteoriteRainGame) {
829 | // Start the game after a short delay to ensure DOM is ready
830 | setTimeout(() => {
831 | if (currentGame && currentGame.startGame) {
832 | currentGame.startGame();
833 | }
834 | }, 100);
835 | }
836 |
837 | // Re-render text if it's already loaded (not for meteorite rain)
838 | if (originalText.length > 0 && gameType !== 'meteoriteRain') {
839 | renderText();
840 | }
841 | }
842 |
843 | // Keyboard layout definition
844 | const keyboardLayout = [
845 | ['`', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '=', 'backspace'],
846 | ['tab', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '[', ']', '\\'],
847 | ['caps', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', "'", 'enter'],
848 | ['shift', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '/', 'shift'],
849 | ['space']
850 | ];
851 |
852 | // Map special keys to display names
853 | const keyDisplayNames = {
854 | 'backspace': '⌫',
855 | 'tab': 'Tab',
856 | 'caps': 'Caps',
857 | 'enter': 'Enter',
858 | 'shift': 'Shift',
859 | 'space': 'Space'
860 | };
861 |
862 | // Check if a key is available for typing
863 | // Accepts both character values (from input) and KeyboardEvent.key values
864 | function isKeyAvailable(key) {
865 | // Space, comma, dot, backspace, and enter are ALWAYS available
866 | const keyLower = key.toLowerCase();
867 | if (key === ' ' || key === '\u00A0' || key === ',' || key === '.' ||
868 | key === 'Backspace' || key === '\b' || key === 'Enter' || key === 'Return' ||
869 | key === '\n' || key === '\r' ||
870 | keyLower === 'space' || keyLower === 'comma' || keyLower === 'dot' ||
871 | keyLower === 'backspace' || keyLower === 'enter') {
872 | return true;
873 | }
874 |
875 | // If no available keys are configured, all keys are available
876 | if (availableKeysSet.size === 0) {
877 | return true;
878 | }
879 |
880 | // Handle KeyboardEvent.key values (e.g., "Tab")
881 | if (key === 'Tab' || keyLower === 'tab') {
882 | return availableKeysSet.has('tab');
883 | }
884 |
885 | // Handle character values (from input events)
886 | if (key === '\t') {
887 | return availableKeysSet.has('tab');
888 | }
889 |
890 | // For regular keys, normalize to lowercase and check
891 | // Handle both single characters and KeyboardEvent.key values
892 | const normalizedKey = key.length === 1 ? key.toLowerCase() : keyLower;
893 | return availableKeysSet.has(normalizedKey);
894 | }
895 |
896 | // Get key element by character
897 | function getKeyElement(char) {
898 | if (!keyboardContainer) return null;
899 |
900 | // Normalize character
901 | const normalizedChar = char.toLowerCase();
902 |
903 | // Handle special keys
904 | if (char === ' ') {
905 | return keyboardContainer.querySelector('[data-key="space"]');
906 | }
907 | if (char === '\n' || char === '\r') {
908 | return keyboardContainer.querySelector('[data-key="enter"]');
909 | }
910 | if (char === '\t') {
911 | return keyboardContainer.querySelector('[data-key="tab"]');
912 | }
913 |
914 | // Find regular key
915 | return keyboardContainer.querySelector(`[data-key="${normalizedChar}"]`);
916 | }
917 |
918 | // Highlight a key on the keyboard
919 | function highlightKey(char, isError = false) {
920 | // Don't highlight unavailable keys
921 | if (!isKeyAvailable(char)) {
922 | return;
923 | }
924 |
925 | // Clear previous highlight
926 | if (activeKeyElement) {
927 | activeKeyElement.classList.remove('active', 'active-error');
928 | }
929 |
930 | // Clear timeout if exists
931 | if (activeKeyTimeout) {
932 | clearTimeout(activeKeyTimeout);
933 | }
934 |
935 | const keyElement = getKeyElement(char);
936 | if (keyElement) {
937 | activeKeyElement = keyElement;
938 | if (isError) {
939 | keyElement.classList.add('active-error');
940 | } else {
941 | keyElement.classList.add('active');
942 | }
943 |
944 | // Remove highlight after animation
945 | activeKeyTimeout = setTimeout(() => {
946 | if (keyElement) {
947 | keyElement.classList.remove('active', 'active-error');
948 | }
949 | activeKeyElement = null;
950 | }, 200);
951 | }
952 | }
953 |
954 | // Render the keyboard
955 | function renderKeyboard() {
956 | if (!keyboardContainer) return;
957 |
958 | const keyboard = document.createElement('div');
959 | keyboard.className = 'keyboard';
960 |
961 | keyboardLayout.forEach(row => {
962 | const rowElement = document.createElement('div');
963 | rowElement.className = 'keyboard-row';
964 |
965 | row.forEach(key => {
966 | const keyElement = document.createElement('div');
967 | const normalizedKey = key.toLowerCase();
968 | keyElement.className = 'keyboard-key';
969 | keyElement.setAttribute('data-key', normalizedKey);
970 |
971 | // Check if this key is available (use isKeyAvailable to ensure space, comma, dot are always available)
972 | const isAvailable = isKeyAvailable(key);
973 | if (!isAvailable) {
974 | keyElement.classList.add('unavailable');
975 | }
976 |
977 | // Add special class for certain keys
978 | if (key === 'space' || key === 'enter' || key === 'shift' ||
979 | key === 'backspace' || key === 'tab' || key === 'caps') {
980 | keyElement.classList.add(key);
981 | }
982 |
983 | // Set display text
984 | if (keyDisplayNames[key]) {
985 | keyElement.textContent = keyDisplayNames[key];
986 | } else {
987 | keyElement.textContent = key.toUpperCase();
988 | }
989 |
990 | rowElement.appendChild(keyElement);
991 | });
992 |
993 | keyboard.appendChild(rowElement);
994 | });
995 |
996 | keyboardContainer.innerHTML = '';
997 | keyboardContainer.appendChild(keyboard);
998 | }
999 |
1000 | // Initialize keyboard
1001 | function initializeKeyboard() {
1002 | keyboardContainer = document.getElementById('keyboard-container');
1003 | if (!keyboardContainer) return;
1004 |
1005 | keyboardEnabled = config.keyboard === true;
1006 |
1007 | if (keyboardEnabled) {
1008 | renderKeyboard();
1009 | keyboardContainer.classList.add('visible');
1010 | } else {
1011 | keyboardContainer.classList.remove('visible');
1012 | }
1013 | }
1014 |
1015 | async function loadText() {
1016 | try {
1017 | setStatus('Loading...');
1018 | const response = await fetch('./text-to-input.txt');
1019 | if (!response.ok) {
1020 | throw new Error('Failed to load text file');
1021 | }
1022 | originalText = await response.text();
1023 | // Replace all newlines with spaces (for single-line display in racing mode)
1024 | originalText = originalText.replace(/\n/g, ' ');
1025 | // Trim trailing whitespace
1026 | originalText = originalText.trimEnd();
1027 |
1028 | // Initialize character states
1029 | charStates.length = 0;
1030 | for (let i = 0; i < originalText.length; i++) {
1031 | charStates.push('pending');
1032 | }
1033 |
1034 | renderText();
1035 | setStatus('Ready');
1036 | } catch (error) {
1037 | console.error('Error loading text:', error);
1038 | setStatus('Failed to load data');
1039 | if (textContainer) {
1040 | textContainer.innerHTML = 'Error: Could not load text file.
';
1041 | }
1042 | }
1043 | }
1044 |
1045 | function renderText() {
1046 | // Calculate correct characters count
1047 | let correctCharsCount = 0;
1048 | for (let i = 0; i < charStates.length; i++) {
1049 | if (charStates[i] === 'correct') {
1050 | correctCharsCount++;
1051 | }
1052 | }
1053 |
1054 | // Check if completed based on correct characters requirement
1055 | const mistakesAllowed = config.racing?.mistakesAllowed ?? 0;
1056 | const requiredCorrectChars = originalText.length - mistakesAllowed;
1057 |
1058 | if (correctCharsCount >= requiredCorrectChars && originalText.length > 0) {
1059 | console.log('Completion detected! Showing completion screen.');
1060 | console.log('Correct chars:', correctCharsCount, 'Required:', requiredCorrectChars);
1061 |
1062 | // For racing game, mark player as winner if not already finished
1063 | if (config.gameType === 'racing' && currentGame && !currentGame.isFinished) {
1064 | currentGame.isFinished = true;
1065 | currentGame.playerWon = true;
1066 | }
1067 |
1068 | showCompletionScreen();
1069 | return;
1070 | }
1071 |
1072 | // Hide completion screen if visible
1073 | if (completionScreen) {
1074 | completionScreen.style.display = 'none';
1075 | }
1076 |
1077 | // Calculate progress for racing game based on correct characters and required amount
1078 | // (reuse mistakesAllowed and requiredCorrectChars from above)
1079 | const progress = requiredCorrectChars > 0 ? Math.min(correctCharsCount / requiredCorrectChars, 1.0) : 0;
1080 |
1081 | // Render text based on game type
1082 | const isRacing = config.gameType === 'racing';
1083 | let html = '';
1084 | const currentPosition = typedText.length;
1085 |
1086 | for (let i = 0; i < originalText.length; i++) {
1087 | const char = originalText[i];
1088 | const state = charStates[i];
1089 | let className = 'char-';
1090 |
1091 | if (i < typedText.length) {
1092 | // Character has been typed
1093 | if (state === 'incorrect') {
1094 | className += 'incorrect';
1095 | } else {
1096 | className += 'correct';
1097 | }
1098 | } else {
1099 | // Character not yet typed
1100 | className += 'pending';
1101 | }
1102 |
1103 | // Handle special characters that need escaping
1104 | let displayChar = char;
1105 | const isSpace = char === ' ';
1106 | if (isSpace) {
1107 | displayChar = '\u00A0'; // Non-breaking space
1108 | className += ' char-space'; // Add class to identify spaces
1109 | } else if (char === '\n') {
1110 | // For racing, convert newlines to spaces (single line display)
1111 | displayChar = isRacing ? '\u00A0' : ' ';
1112 | if (isRacing) {
1113 | className += ' char-space'; // Add class for newlines converted to spaces
1114 | }
1115 | } else {
1116 | displayChar = escapeHtml(char);
1117 | }
1118 |
1119 | // Add cursor class to the character at the typing position
1120 | if (i === currentPosition) {
1121 | className += ' cursor-position';
1122 | }
1123 |
1124 | html += `${displayChar} `;
1125 | }
1126 |
1127 | // If all characters are typed, add a cursor position marker at the end
1128 | if (currentPosition === originalText.length) {
1129 | html += '\u00A0 ';
1130 | }
1131 |
1132 | // Use game's renderText method
1133 | if (currentGame && currentGame.renderText) {
1134 | currentGame.renderText(html);
1135 | } else if (textContainer) {
1136 | // Fallback to classic rendering
1137 | textContainer.innerHTML = html;
1138 | }
1139 |
1140 | // Update player position in racing game (car follows cursor)
1141 | if (isRacing && currentGame && currentGame.updatePlayerPosition) {
1142 | currentGame.updatePlayerPosition();
1143 | }
1144 | }
1145 |
1146 | function escapeHtml(text) {
1147 | const div = document.createElement('div');
1148 | div.textContent = text;
1149 | return div.innerHTML;
1150 | }
1151 |
1152 | function handleInput(e) {
1153 | let input = e.target.value;
1154 |
1155 | // Special handling for meteorite rain game (word-based typing)
1156 | if (config.gameType === 'meteoriteRain' && currentGame instanceof MeteoriteRainGame) {
1157 | // Don't allow typing if game hasn't started
1158 | if (!currentGame.hasStarted) {
1159 | e.target.value = '';
1160 | return;
1161 | }
1162 |
1163 | // Filter out unavailable keys if availableKeys is configured
1164 | if (availableKeysSet.size > 0) {
1165 | let filteredInput = '';
1166 | for (let i = 0; i < input.length; i++) {
1167 | const char = input[i];
1168 | if (isKeyAvailable(char)) {
1169 | filteredInput += char;
1170 | }
1171 | }
1172 | input = filteredInput;
1173 | e.target.value = input;
1174 | }
1175 |
1176 | // Update game with current typed word (no spaces allowed in word input)
1177 | const currentWord = input.trim();
1178 | if (currentGame.setTypedWord) {
1179 | currentGame.setTypedWord(currentWord);
1180 | }
1181 |
1182 | // Highlight keys for meteorite rain
1183 | if (keyboardEnabled && currentWord.length > 0) {
1184 | const lastChar = currentWord[currentWord.length - 1];
1185 | highlightKey(lastChar, false);
1186 | }
1187 |
1188 | return; // Don't process further for meteorite rain (Enter key handled in handleKeyDown)
1189 | }
1190 |
1191 | // Original character-by-character handling for other game types
1192 | // Filter out unavailable keys if availableKeys is configured
1193 | if (availableKeysSet.size > 0) {
1194 | let filteredInput = '';
1195 | for (let i = 0; i < input.length; i++) {
1196 | const char = input[i];
1197 | if (isKeyAvailable(char)) {
1198 | filteredInput += char;
1199 | }
1200 | }
1201 | input = filteredInput;
1202 | e.target.value = input;
1203 | }
1204 |
1205 | // Start timer on first keypress
1206 | if (startTime === null && input.length > 0) {
1207 | startTime = Date.now();
1208 | }
1209 |
1210 | // Prevent typing beyond the original text length
1211 | if (input.length > originalText.length) {
1212 | input = input.slice(0, originalText.length);
1213 | e.target.value = input;
1214 | }
1215 |
1216 | const inputLength = input.length;
1217 | const typedLength = typedText.length;
1218 |
1219 | // Handle typing forward
1220 | if (inputLength > typedLength) {
1221 | const newChars = input.slice(typedLength);
1222 | let validInput = typedText; // Start with current valid text
1223 |
1224 | for (let i = 0; i < newChars.length; i++) {
1225 | const charIndex = typedLength + i;
1226 | if (charIndex >= originalText.length) {
1227 | break;
1228 | }
1229 |
1230 | const expectedChar = originalText[charIndex];
1231 | const typedChar = newChars[i];
1232 |
1233 | totalInputs++; // Track total inputs
1234 |
1235 | const isError = typedChar !== expectedChar;
1236 | if (isError) {
1237 | // Don't add incorrect character to input, but count as error
1238 | totalErrors++; // Track total errors
1239 |
1240 | // Highlight keyboard key to show error
1241 | if (keyboardEnabled) {
1242 | highlightKey(typedChar, true);
1243 | }
1244 |
1245 | // Reset input to valid text (reject the incorrect character)
1246 | e.target.value = validInput;
1247 | input = validInput;
1248 | break; // Stop processing further characters
1249 | } else {
1250 | // Character is correct - add it to valid input
1251 | validInput += typedChar;
1252 | charStates[charIndex] = 'correct';
1253 |
1254 | // Highlight keyboard key
1255 | if (keyboardEnabled) {
1256 | highlightKey(typedChar, false);
1257 | }
1258 | }
1259 | }
1260 | typedText = validInput;
1261 | }
1262 | // Handle backspace/delete
1263 | else if (inputLength < typedLength) {
1264 | typedText = input;
1265 | // Reset states for characters that are no longer typed
1266 | for (let i = inputLength; i < originalText.length; i++) {
1267 | if (i < charStates.length) {
1268 | charStates[i] = 'pending';
1269 | }
1270 | }
1271 |
1272 | // Highlight backspace key (only if available)
1273 | if (keyboardEnabled && isKeyAvailable('backspace')) {
1274 | highlightKey('backspace', false);
1275 | }
1276 | }
1277 |
1278 | renderText();
1279 | updateRealtimeStats();
1280 | }
1281 |
1282 | function handleKeyDown(e) {
1283 | // Special handling for meteorite rain game - Space key submits word or starts game
1284 | if (config.gameType === 'meteoriteRain' && currentGame instanceof MeteoriteRainGame) {
1285 | // Space key submits word or starts game
1286 | if (e.key === ' ' || e.key === 'Space') {
1287 | e.preventDefault(); // Prevent default space behavior
1288 |
1289 | // If game hasn't started, start it
1290 | if (!currentGame.hasStarted) {
1291 | currentGame.beginGame();
1292 | return;
1293 | }
1294 |
1295 | const inputElement = document.getElementById('meteorite-typing-input');
1296 | if (inputElement) {
1297 | const wordToCheck = inputElement.value.trim();
1298 | if (wordToCheck.length > 0) {
1299 | // Check if word matches a meteorite
1300 | if (currentGame.checkWordMatch && currentGame.checkWordMatch(wordToCheck)) {
1301 | // Word matched! Clear input
1302 | inputElement.value = '';
1303 | if (currentGame.setTypedWord) {
1304 | currentGame.setTypedWord('');
1305 | }
1306 | } else {
1307 | // Word didn't match, clear input anyway (player can try again)
1308 | inputElement.value = '';
1309 | if (currentGame.setTypedWord) {
1310 | currentGame.setTypedWord('');
1311 | }
1312 | }
1313 | }
1314 | }
1315 | return;
1316 | }
1317 |
1318 | // Enter key can also start the game (for convenience)
1319 | if ((e.key === 'Enter' || e.key === 'Return') && !currentGame.hasStarted) {
1320 | e.preventDefault();
1321 | currentGame.beginGame();
1322 | return;
1323 | }
1324 | }
1325 |
1326 | // Handle Enter key for other game types - check availability but let textarea handle insertion
1327 | if (e.key === 'Enter' || e.key === 'Return') {
1328 | if (!isKeyAvailable('\n')) {
1329 | e.preventDefault(); // Prevent if not available
1330 | return;
1331 | }
1332 |
1333 | // Check if we can still type (not beyond original text length)
1334 | if (hiddenInput.value.length >= originalText.length) {
1335 | e.preventDefault(); // Can't type beyond original text
1336 | return;
1337 | }
1338 |
1339 | // Let the browser handle the newline insertion naturally
1340 | // Highlight keyboard key if enabled
1341 | if (keyboardEnabled) {
1342 | // Use setTimeout to highlight after the newline is inserted
1343 | setTimeout(() => {
1344 | highlightKey('\n', false);
1345 | }, 0);
1346 | }
1347 |
1348 | // The input event will fire naturally, no need to manually trigger
1349 | return;
1350 | }
1351 |
1352 | // Handle Tab key - manually insert tab character
1353 | if (e.key === 'Tab') {
1354 | e.preventDefault(); // Prevent tab from moving focus
1355 |
1356 | if (!isKeyAvailable('\t')) {
1357 | return; // Key not available, don't insert
1358 | }
1359 |
1360 | // Check if we can still type (not beyond original text length)
1361 | if (hiddenInput.value.length >= originalText.length) {
1362 | return; // Can't type beyond original text
1363 | }
1364 |
1365 | // Get current cursor position
1366 | const cursorPos = hiddenInput.selectionStart || hiddenInput.value.length;
1367 |
1368 | // Insert tab at cursor position
1369 | const currentValue = hiddenInput.value;
1370 | const newValue = currentValue.slice(0, cursorPos) + '\t' + currentValue.slice(cursorPos);
1371 |
1372 | // Update input value
1373 | hiddenInput.value = newValue;
1374 |
1375 | // Move cursor after the inserted tab
1376 | setTimeout(() => {
1377 | hiddenInput.setSelectionRange(cursorPos + 1, cursorPos + 1);
1378 | }, 0);
1379 |
1380 | // Highlight keyboard key if enabled
1381 | if (keyboardEnabled) {
1382 | highlightKey('\t', false);
1383 | }
1384 |
1385 | // Manually trigger input event to process the tab
1386 | const inputEvent = new Event('input', { bubbles: true });
1387 | hiddenInput.dispatchEvent(inputEvent);
1388 |
1389 | return;
1390 | }
1391 |
1392 | // Prevent unavailable keys from being typed (skip for meteorite rain - it has its own input)
1393 | if (config.gameType !== 'meteoriteRain' && availableKeysSet.size > 0 && !isKeyAvailable(e.key)) {
1394 | e.preventDefault();
1395 | return;
1396 | }
1397 |
1398 | // Prevent default behavior for backspace when at start (only for hiddenInput, not meteorite input)
1399 | if (e.key === 'Backspace' && e.target === hiddenInput && hiddenInput.value.length === 0) {
1400 | e.preventDefault();
1401 | }
1402 | }
1403 |
1404 | function restart() {
1405 | typedText = '';
1406 | for (let i = 0; i < charStates.length; i++) {
1407 | charStates[i] = 'pending';
1408 | }
1409 | if (hiddenInput) {
1410 | hiddenInput.value = '';
1411 | }
1412 |
1413 | // Reset statistics
1414 | startTime = null;
1415 | totalErrors = 0;
1416 | totalInputs = 0;
1417 |
1418 | // Clear real-time stats interval
1419 | if (realtimeStatsInterval) {
1420 | clearInterval(realtimeStatsInterval);
1421 | realtimeStatsInterval = null;
1422 | }
1423 |
1424 | // Update real-time stats display
1425 | updateRealtimeStats();
1426 |
1427 | // Clear keyboard highlights
1428 | if (activeKeyElement) {
1429 | activeKeyElement.classList.remove('active', 'active-error');
1430 | activeKeyElement = null;
1431 | }
1432 | if (activeKeyTimeout) {
1433 | clearTimeout(activeKeyTimeout);
1434 | activeKeyTimeout = null;
1435 | }
1436 |
1437 | // Reset game
1438 | if (currentGame && currentGame.reset) {
1439 | currentGame.reset();
1440 | }
1441 |
1442 | // Show appropriate container and hide completion screen and stats dashboard
1443 | const isRacing = config.gameType === 'racing';
1444 | const isMeteoriteRain = config.gameType === 'meteoriteRain';
1445 |
1446 | if (isRacing) {
1447 | const racingContainer = document.getElementById('racing-track-container');
1448 | if (racingContainer) {
1449 | racingContainer.style.display = 'block';
1450 | }
1451 | const classicContainer = document.getElementById('classic-typing-container');
1452 | if (classicContainer) {
1453 | classicContainer.style.display = 'none';
1454 | }
1455 | const meteoriteContainer = document.getElementById('meteorite-rain-container');
1456 | if (meteoriteContainer) {
1457 | meteoriteContainer.style.display = 'none';
1458 | }
1459 | } else if (isMeteoriteRain) {
1460 | const meteoriteContainer = document.getElementById('meteorite-rain-container');
1461 | if (meteoriteContainer) {
1462 | meteoriteContainer.style.display = 'flex';
1463 | }
1464 | const classicContainer = document.getElementById('classic-typing-container');
1465 | if (classicContainer) {
1466 | classicContainer.style.display = 'none';
1467 | }
1468 | const racingContainer = document.getElementById('racing-track-container');
1469 | if (racingContainer) {
1470 | racingContainer.style.display = 'none';
1471 | }
1472 | // Restart meteorite rain game
1473 | if (currentGame && currentGame.startGame) {
1474 | setTimeout(() => {
1475 | if (currentGame && currentGame.startGame) {
1476 | currentGame.startGame();
1477 | }
1478 | }, 100);
1479 | }
1480 | } else {
1481 | const typingTextContainer = document.querySelector('.typing-text-container');
1482 | if (typingTextContainer) {
1483 | typingTextContainer.style.display = 'block';
1484 | }
1485 | const racingContainer = document.getElementById('racing-track-container');
1486 | if (racingContainer) {
1487 | racingContainer.style.display = 'none';
1488 | }
1489 | const meteoriteContainer = document.getElementById('meteorite-rain-container');
1490 | if (meteoriteContainer) {
1491 | meteoriteContainer.style.display = 'none';
1492 | }
1493 | }
1494 |
1495 | if (completionScreen) {
1496 | completionScreen.style.display = 'none';
1497 | }
1498 | if (statsDashboard) {
1499 | statsDashboard.style.display = 'none';
1500 | }
1501 |
1502 | // Show real-time stats again if configured
1503 | if (realtimeStatsContainer) {
1504 | updateRealtimeStats();
1505 | }
1506 |
1507 | // Show keyboard-stats-wrapper again
1508 | const keyboardStatsWrapper = document.querySelector('.keyboard-stats-wrapper');
1509 | if (keyboardStatsWrapper) {
1510 | keyboardStatsWrapper.style.display = 'block';
1511 | }
1512 |
1513 | // Show keyboard again if it was enabled
1514 | if (keyboardContainer && keyboardEnabled) {
1515 | keyboardContainer.classList.add('visible');
1516 | }
1517 |
1518 | // Show the restart button again
1519 | if (restartButton && restartButton.parentElement) {
1520 | restartButton.parentElement.style.display = 'block';
1521 | }
1522 |
1523 | renderText();
1524 | setStatus('Ready');
1525 |
1526 | // Focus the appropriate input after a short delay
1527 | setTimeout(() => {
1528 | if (isMeteoriteRain) {
1529 | const meteoriteInput = document.getElementById('meteorite-typing-input');
1530 | if (meteoriteInput) {
1531 | meteoriteInput.focus();
1532 | }
1533 | } else if (hiddenInput) {
1534 | hiddenInput.focus();
1535 | }
1536 | }, 50);
1537 | }
1538 |
1539 | // Calculate real-time statistics (while typing)
1540 | function calculateRealtimeStats() {
1541 | // Calculate chars typed and total
1542 | const charsTyped = typedText.length;
1543 | const charsTotal = originalText.length;
1544 |
1545 | if (startTime === null) {
1546 | return {
1547 | speed: 0,
1548 | accuracy: 0,
1549 | time: 0,
1550 | errors: 0,
1551 | errorsLeft: 0,
1552 | chars: { typed: charsTyped, total: charsTotal }
1553 | };
1554 | }
1555 |
1556 | const currentTime = Date.now();
1557 | const totalTimeSeconds = (currentTime - startTime) / 1000;
1558 | const totalTimeMinutes = totalTimeSeconds / 60;
1559 |
1560 | // Count errors left (unfixed incorrect characters)
1561 | let errorsLeft = 0;
1562 | for (let i = 0; i < charStates.length; i++) {
1563 | if (charStates[i] === 'incorrect') {
1564 | errorsLeft++;
1565 | }
1566 | }
1567 |
1568 | // Calculate accuracy: (correct inputs / total inputs) * 100
1569 | const correctInputs = totalInputs - totalErrors;
1570 | const accuracy = totalInputs > 0 ? (correctInputs / totalInputs) * 100 : 0;
1571 |
1572 | // Calculate words per minute
1573 | // Count words by splitting on whitespace
1574 | const wordsTyped = originalText.trim().split(/\s+/).filter(word => word.length > 0).length;
1575 | const wpm = totalTimeMinutes > 0 ? wordsTyped / totalTimeMinutes : 0;
1576 |
1577 | return {
1578 | speed: wpm,
1579 | accuracy: accuracy,
1580 | time: totalTimeSeconds,
1581 | errors: totalErrors,
1582 | errorsLeft: errorsLeft,
1583 | chars: { typed: charsTyped, total: charsTotal }
1584 | };
1585 | }
1586 |
1587 | // Update real-time stats display
1588 | function updateRealtimeStats() {
1589 | if (!realtimeStatsContainer) return;
1590 |
1591 | // Check if realTimeStats is configured and has items
1592 | if (!config.realTimeStats || !Array.isArray(config.realTimeStats) || config.realTimeStats.length === 0) {
1593 | realtimeStatsContainer.style.display = 'none';
1594 | // Clear interval if stats are disabled
1595 | if (realtimeStatsInterval) {
1596 | clearInterval(realtimeStatsInterval);
1597 | realtimeStatsInterval = null;
1598 | }
1599 | return;
1600 | }
1601 |
1602 | const stats = calculateRealtimeStats();
1603 | realtimeStatsContainer.style.display = 'flex';
1604 |
1605 | // Clear existing content
1606 | realtimeStatsContainer.innerHTML = '';
1607 |
1608 | // Map of stat keys to display info
1609 | const statMap = {
1610 | speed: { label: 'WPM', value: stats.speed, format: (v) => v.toFixed(1) },
1611 | accuracy: { label: 'Accuracy', value: stats.accuracy, format: (v) => v.toFixed(1) + '%' },
1612 | time: { label: 'Time', value: stats.time, format: (v) => {
1613 | if (v < 60) {
1614 | return v.toFixed(1) + 's';
1615 | } else {
1616 | const minutes = Math.floor(v / 60);
1617 | const seconds = (v % 60).toFixed(1);
1618 | return `${minutes}m ${seconds}s`;
1619 | }
1620 | }},
1621 | errors: { label: 'Errors', value: stats.errors, format: (v) => Math.round(v).toString() },
1622 | errorsLeft: { label: 'Errors Left', value: stats.errorsLeft, format: (v) => Math.round(v).toString() },
1623 | chars: { label: 'Chars', value: stats.chars, format: (v) => `${v.typed}/${v.total}` }
1624 | };
1625 |
1626 | // Create stat items for each configured stat
1627 | config.realTimeStats.forEach(statKey => {
1628 | const statInfo = statMap[statKey];
1629 | if (!statInfo) return; // Skip invalid stat keys
1630 |
1631 | const statItem = document.createElement('div');
1632 | statItem.className = 'realtime-stat-item';
1633 |
1634 | const statLabel = document.createElement('span');
1635 | statLabel.className = 'realtime-stat-label';
1636 | statLabel.textContent = statInfo.label;
1637 |
1638 | const statValue = document.createElement('span');
1639 | statValue.className = 'realtime-stat-value';
1640 | statValue.textContent = statInfo.format(statInfo.value);
1641 |
1642 | statItem.appendChild(statLabel);
1643 | statItem.appendChild(statValue);
1644 | realtimeStatsContainer.appendChild(statItem);
1645 | });
1646 |
1647 | // Start periodic updates if typing has started and interval not already running
1648 | if (startTime !== null && !realtimeStatsInterval) {
1649 | realtimeStatsInterval = setInterval(() => {
1650 | updateRealtimeStats();
1651 | }, 100); // Update every 100ms for smooth time updates
1652 | }
1653 | }
1654 |
1655 | function calculateStatistics() {
1656 | console.log('Calculating statistics...');
1657 | console.log('startTime:', startTime, 'totalInputs:', totalInputs, 'totalErrors:', totalErrors);
1658 |
1659 | if (startTime === null) {
1660 | console.log('No typing started, returning null');
1661 | return null; // No typing started
1662 | }
1663 |
1664 | const endTime = Date.now();
1665 | const totalTimeSeconds = (endTime - startTime) / 1000;
1666 | const totalTimeMinutes = totalTimeSeconds / 60;
1667 |
1668 | // Count errors left (unfixed incorrect characters)
1669 | let errorsLeft = 0;
1670 | for (let i = 0; i < charStates.length; i++) {
1671 | if (charStates[i] === 'incorrect') {
1672 | errorsLeft++;
1673 | }
1674 | }
1675 |
1676 | // Calculate accuracy: (correct inputs / total inputs) * 100
1677 | const correctInputs = totalInputs - totalErrors;
1678 | const accuracy = totalInputs > 0 ? (correctInputs / totalInputs) * 100 : 0;
1679 |
1680 | // Calculate words per minute
1681 | // Count words by splitting on whitespace
1682 | const wordsTyped = originalText.trim().split(/\s+/).filter(word => word.length > 0).length;
1683 | const wpm = totalTimeMinutes > 0 ? wordsTyped / totalTimeMinutes : 0;
1684 |
1685 | const stats = {
1686 | totalErrors: totalErrors,
1687 | errorsLeft: errorsLeft,
1688 | totalTime: totalTimeSeconds,
1689 | accuracy: accuracy,
1690 | speed: wpm
1691 | };
1692 |
1693 | console.log('Calculated statistics:', stats);
1694 | return stats;
1695 | }
1696 |
1697 | async function saveStatistics(stats) {
1698 | console.log('saveStatistics called with:', stats);
1699 | try {
1700 | // Get win/lose status for racing games or score for meteorite rain
1701 | let statusLine = '';
1702 | if (config.gameType === 'racing' && currentGame && currentGame.playerWon !== null) {
1703 | const status = currentGame.playerWon ? 'win' : 'lose';
1704 | statusLine = `Status: ${status}\n\n`;
1705 | } else if (config.gameType === 'meteoriteRain' && currentGame instanceof MeteoriteRainGame) {
1706 | const score = currentGame.getScore ? currentGame.getScore() : 0;
1707 | statusLine = `Score: ${score}\n\n`;
1708 | }
1709 |
1710 | // Format statistics text
1711 | const statsText = `${statusLine}Typing Statistics
1712 | ==================
1713 |
1714 | Total Errors Made: ${stats.totalErrors}
1715 | Errors Left (Unfixed): ${stats.errorsLeft}
1716 | Total Time: ${stats.totalTime.toFixed(2)} seconds
1717 | Accuracy: ${stats.accuracy.toFixed(2)}%
1718 | Speed: ${stats.speed.toFixed(2)} words per minute
1719 |
1720 | Generated: ${new Date().toLocaleString()}
1721 | `;
1722 |
1723 | console.log('Sending stats to server:', statsText);
1724 | const response = await fetch('/save-stats', {
1725 | method: 'POST',
1726 | headers: {
1727 | 'Content-Type': 'text/plain',
1728 | },
1729 | body: statsText
1730 | });
1731 |
1732 | console.log('Server response status:', response.status);
1733 |
1734 | if (response.ok) {
1735 | const result = await response.json();
1736 | console.log('Statistics saved to client/stats.txt', result);
1737 | } else {
1738 | const errorText = await response.text();
1739 | console.error('Failed to save statistics:', response.status, errorText);
1740 | }
1741 | } catch (error) {
1742 | console.error('Error saving statistics:', error);
1743 | }
1744 | }
1745 |
1746 | // Parse stats from stats.txt file
1747 | function parseStatsText(statsText) {
1748 | const stats = {};
1749 | const lines = statsText.split('\n');
1750 |
1751 | for (const line of lines) {
1752 | if (line.includes('Total Errors Made:')) {
1753 | const match = line.match(/Total Errors Made:\s*(\d+)/);
1754 | if (match) stats.totalErrors = parseInt(match[1], 10);
1755 | } else if (line.includes('Errors Left (Unfixed):')) {
1756 | const match = line.match(/Errors Left \(Unfixed\):\s*(\d+)/);
1757 | if (match) stats.errorsLeft = parseInt(match[1], 10);
1758 | } else if (line.includes('Total Time:')) {
1759 | const match = line.match(/Total Time:\s*([\d.]+)\s*seconds/);
1760 | if (match) stats.totalTime = parseFloat(match[1]);
1761 | } else if (line.includes('Accuracy:')) {
1762 | const match = line.match(/Accuracy:\s*([\d.]+)%/);
1763 | if (match) stats.accuracy = parseFloat(match[1]);
1764 | } else if (line.includes('Speed:')) {
1765 | const match = line.match(/Speed:\s*([\d.]+)\s*words per minute/);
1766 | if (match) stats.speed = parseFloat(match[1]);
1767 | }
1768 | }
1769 |
1770 | return stats;
1771 | }
1772 |
1773 | // Load and display stats dashboard
1774 | async function showStatsDashboard() {
1775 | // Hide typing container
1776 | const typingTextContainer = document.getElementById('classic-typing-container');
1777 | if (typingTextContainer) {
1778 | typingTextContainer.style.display = 'none';
1779 | }
1780 |
1781 | // Hide racing track
1782 | const racingTrackContainer = document.getElementById('racing-track-container');
1783 | if (racingTrackContainer) {
1784 | racingTrackContainer.style.display = 'none';
1785 | }
1786 |
1787 | // Hide meteorite rain container
1788 | const meteoriteRainContainer = document.getElementById('meteorite-rain-container');
1789 | if (meteoriteRainContainer) {
1790 | meteoriteRainContainer.style.display = 'none';
1791 | }
1792 |
1793 | // Hide the restart button when dashboard is shown
1794 | if (restartButton && restartButton.parentElement) {
1795 | restartButton.parentElement.style.display = 'none';
1796 | }
1797 |
1798 | // Hide keyboard when dashboard is shown
1799 | if (keyboardContainer) {
1800 | keyboardContainer.classList.remove('visible');
1801 | }
1802 |
1803 | // Hide real-time stats when dashboard is shown
1804 | if (realtimeStatsContainer) {
1805 | realtimeStatsContainer.style.display = 'none';
1806 | }
1807 |
1808 | // Hide keyboard-stats-wrapper when dashboard is shown
1809 | const keyboardStatsWrapper = document.querySelector('.keyboard-stats-wrapper');
1810 | if (keyboardStatsWrapper) {
1811 | keyboardStatsWrapper.style.display = 'none';
1812 | }
1813 |
1814 | // Hide completion screen if visible
1815 | if (completionScreen) {
1816 | completionScreen.style.display = 'none';
1817 | }
1818 |
1819 | try {
1820 | const response = await fetch('./stats.txt');
1821 | let stats = null;
1822 |
1823 | if (response.ok) {
1824 | const statsText = await response.text();
1825 | stats = parseStatsText(statsText);
1826 | } else {
1827 | console.warn('Stats file not found, using default values');
1828 | // Create default stats object with zeros
1829 | stats = {
1830 | totalErrors: 0,
1831 | errorsLeft: 0,
1832 | totalTime: 0,
1833 | accuracy: 0,
1834 | speed: 0
1835 | };
1836 | }
1837 |
1838 | // Update dashboard header based on game type
1839 | const dashboardHeader = statsDashboard ? statsDashboard.querySelector('.stats-dashboard-header h2') : null;
1840 | if (dashboardHeader) {
1841 | if (config.gameType === 'meteoriteRain' && currentGame instanceof MeteoriteRainGame) {
1842 | // Show final score for meteorite rain
1843 | const score = currentGame.getScore ? currentGame.getScore() : 0;
1844 | dashboardHeader.textContent = `Final Score: ${score}`;
1845 | } else if (config.gameType === 'racing' && currentGame && currentGame.playerWon !== null) {
1846 | if (currentGame.playerWon === true) {
1847 | dashboardHeader.textContent = 'Victory 🏅';
1848 | } else if (currentGame.playerWon === false) {
1849 | dashboardHeader.textContent = 'You lost! 😢';
1850 | } else {
1851 | dashboardHeader.textContent = 'Typing Statistics'; // Fallback
1852 | }
1853 | } else {
1854 | dashboardHeader.textContent = 'Typing Statistics'; // Default for non-racing games
1855 | }
1856 | }
1857 |
1858 | // Update dashboard with stats
1859 | const speedEl = document.getElementById('stat-speed');
1860 | const accuracyEl = document.getElementById('stat-accuracy');
1861 | const timeEl = document.getElementById('stat-time');
1862 | const errorsEl = document.getElementById('stat-errors');
1863 | const errorsLeftEl = document.getElementById('stat-errors-left');
1864 |
1865 | if (speedEl) speedEl.textContent = stats.speed ? stats.speed.toFixed(1) : '0';
1866 | if (accuracyEl) accuracyEl.textContent = stats.accuracy ? stats.accuracy.toFixed(1) + '%' : '0%';
1867 | if (timeEl) {
1868 | const timeValue = stats.totalTime || 0;
1869 | if (timeValue < 60) {
1870 | timeEl.textContent = timeValue.toFixed(1) + 's';
1871 | } else {
1872 | const minutes = Math.floor(timeValue / 60);
1873 | const seconds = (timeValue % 60).toFixed(1);
1874 | timeEl.textContent = `${minutes}m ${seconds}s`;
1875 | }
1876 | }
1877 | if (errorsEl) errorsEl.textContent = stats.totalErrors || 0;
1878 | if (errorsLeftEl) errorsLeftEl.textContent = stats.errorsLeft || 0;
1879 |
1880 | // Show dashboard
1881 | if (statsDashboard) {
1882 | statsDashboard.style.display = 'flex';
1883 | }
1884 |
1885 | if (hiddenInput) {
1886 | hiddenInput.blur();
1887 | }
1888 | } catch (error) {
1889 | console.error('Error loading stats:', error);
1890 | // Fall back to simple completion screen
1891 | // Keyboard is already hidden above
1892 | if (completionScreen) {
1893 | completionScreen.style.display = 'flex';
1894 | }
1895 | }
1896 | }
1897 |
1898 | function showCompletionScreen() {
1899 | console.log('showCompletionScreen called');
1900 |
1901 | // Hide stats dashboard if visible
1902 | if (statsDashboard) {
1903 | statsDashboard.style.display = 'none';
1904 | }
1905 |
1906 | if (!completionScreen) {
1907 | console.error('Completion screen element not found!');
1908 | return;
1909 | }
1910 |
1911 | // Hide typing container
1912 | const typingTextContainer = document.getElementById('classic-typing-container');
1913 | if (typingTextContainer) {
1914 | typingTextContainer.style.display = 'none';
1915 | }
1916 |
1917 | // Hide racing track
1918 | const racingTrackContainer = document.getElementById('racing-track-container');
1919 | if (racingTrackContainer) {
1920 | racingTrackContainer.style.display = 'none';
1921 | }
1922 |
1923 | // Hide meteorite rain container
1924 | const meteoriteRainContainer = document.getElementById('meteorite-rain-container');
1925 | if (meteoriteRainContainer) {
1926 | meteoriteRainContainer.style.display = 'none';
1927 | }
1928 |
1929 | // Hide keyboard when completion screen is shown
1930 | if (keyboardContainer) {
1931 | keyboardContainer.classList.remove('visible');
1932 | }
1933 |
1934 | // Hide real-time stats when completion screen is shown
1935 | if (realtimeStatsContainer) {
1936 | realtimeStatsContainer.style.display = 'none';
1937 | }
1938 |
1939 | // Hide keyboard-stats-wrapper when completion screen is shown
1940 | const keyboardStatsWrapper = document.querySelector('.keyboard-stats-wrapper');
1941 | if (keyboardStatsWrapper) {
1942 | keyboardStatsWrapper.style.display = 'none';
1943 | }
1944 |
1945 | // Hide the restart button when completion screen is shown
1946 | if (restartButton && restartButton.parentElement) {
1947 | restartButton.parentElement.style.display = 'none';
1948 | }
1949 |
1950 | // Calculate and save statistics
1951 | console.log('About to calculate statistics...');
1952 | let stats = calculateStatistics();
1953 | console.log('Statistics result:', stats);
1954 |
1955 | // For meteorite rain games, create stats even if calculateStatistics returns null
1956 | const isMeteoriteRainGame = config.gameType === 'meteoriteRain' && currentGame instanceof MeteoriteRainGame;
1957 | if (isMeteoriteRainGame && (!stats || stats === null)) {
1958 | // Create minimal stats for meteorite rain using game's own timing
1959 | const gameStartTime = currentGame.gameStartTime;
1960 | const endTime = Date.now();
1961 | const totalTimeSeconds = gameStartTime ? (endTime - gameStartTime) / 1000 : 0;
1962 |
1963 | stats = {
1964 | totalErrors: 0,
1965 | errorsLeft: 0,
1966 | totalTime: totalTimeSeconds,
1967 | accuracy: 100, // Not applicable for word-based game, but set to 100
1968 | speed: 0 // Not applicable for word-based game
1969 | };
1970 | console.log('Created stats for meteorite rain game:', stats);
1971 | }
1972 |
1973 | // For racing game or meteorite rain, show dashboard even if stats are null
1974 | const isRacingGame = config.gameType === 'racing' && currentGame;
1975 | const shouldShowDashboard = config.showStats === true || (isRacingGame && currentGame.playerWon !== null) || isMeteoriteRainGame;
1976 |
1977 | if (stats) {
1978 | console.log('Calling saveStatistics...');
1979 | saveStatistics(stats).then(() => {
1980 | // After saving, check if we should show stats dashboard
1981 | if (shouldShowDashboard) {
1982 | // Wait a bit for the file to be written, then show dashboard
1983 | setTimeout(() => {
1984 | showStatsDashboard();
1985 | }, 200);
1986 | } else {
1987 | // Show simple completion screen
1988 | // Ensure real-time stats are hidden
1989 | if (realtimeStatsContainer) {
1990 | realtimeStatsContainer.style.display = 'none';
1991 | }
1992 | completionScreen.style.display = 'flex';
1993 | if (hiddenInput) {
1994 | hiddenInput.blur();
1995 | }
1996 | }
1997 | });
1998 | } else {
1999 | console.log('No statistics to save (stats is null)');
2000 | // For racing game or meteorite rain, still show dashboard
2001 | if (shouldShowDashboard) {
2002 | setTimeout(() => {
2003 | showStatsDashboard();
2004 | }, 200);
2005 | } else {
2006 | // Show simple completion screen
2007 | // Ensure real-time stats are hidden
2008 | if (realtimeStatsContainer) {
2009 | realtimeStatsContainer.style.display = 'none';
2010 | }
2011 | completionScreen.style.display = 'flex';
2012 | if (hiddenInput) {
2013 | hiddenInput.blur();
2014 | }
2015 | }
2016 | }
2017 | }
2018 |
2019 | async function initialize() {
2020 | // Load config first
2021 | await loadConfig();
2022 |
2023 | textContainer = document.getElementById('typing-text');
2024 | hiddenInput = document.getElementById('hidden-input');
2025 | completionScreen = document.getElementById('completion-screen');
2026 | statsDashboard = document.getElementById('stats-dashboard');
2027 | restartButton = document.getElementById('btn-restart');
2028 | startOverButton = document.getElementById('btn-start-over');
2029 | statsStartOverButton = document.getElementById('btn-stats-start-over');
2030 | realtimeStatsContainer = document.getElementById('realtime-stats-container');
2031 |
2032 | if (!hiddenInput) {
2033 | console.error('Required elements not found');
2034 | return;
2035 | }
2036 |
2037 | // Initialize game based on config
2038 | initializeGame();
2039 |
2040 | // Initialize keyboard
2041 | initializeKeyboard();
2042 |
2043 | // Set up event listeners
2044 | hiddenInput.addEventListener('input', handleInput);
2045 | hiddenInput.addEventListener('keydown', handleKeyDown);
2046 |
2047 | // Set up event listeners for meteorite rain input
2048 | const meteoriteInput = document.getElementById('meteorite-typing-input');
2049 | if (meteoriteInput) {
2050 | meteoriteInput.addEventListener('input', handleInput);
2051 | meteoriteInput.addEventListener('keydown', handleKeyDown);
2052 | }
2053 |
2054 | if (restartButton) {
2055 | restartButton.addEventListener('click', restart);
2056 | }
2057 |
2058 | if (startOverButton) {
2059 | startOverButton.addEventListener('click', restart);
2060 | }
2061 |
2062 | if (statsStartOverButton) {
2063 | statsStartOverButton.addEventListener('click', restart);
2064 | }
2065 |
2066 | // Focus the input when clicking on the text container or racing track
2067 | const typingTextContainer = document.querySelector('.typing-text-container');
2068 | const racingTrackContainer = document.getElementById('racing-track-container');
2069 |
2070 | if (typingTextContainer) {
2071 | typingTextContainer.addEventListener('click', () => {
2072 | const isCompletionVisible = completionScreen && completionScreen.style.display === 'flex';
2073 | const isStatsVisible = statsDashboard && statsDashboard.style.display === 'flex';
2074 | if (hiddenInput && !isCompletionVisible && !isStatsVisible) {
2075 | hiddenInput.focus();
2076 | }
2077 | });
2078 | }
2079 |
2080 | if (racingTrackContainer) {
2081 | racingTrackContainer.addEventListener('click', () => {
2082 | const isCompletionVisible = completionScreen && completionScreen.style.display === 'flex';
2083 | const isStatsVisible = statsDashboard && statsDashboard.style.display === 'flex';
2084 | if (hiddenInput && !isCompletionVisible && !isStatsVisible) {
2085 | hiddenInput.focus();
2086 | }
2087 | });
2088 | }
2089 |
2090 | // Focus meteorite input when clicking on play area
2091 | const meteoritePlayArea = document.getElementById('meteorite-play-area');
2092 | if (meteoritePlayArea) {
2093 | meteoritePlayArea.addEventListener('click', () => {
2094 | const isCompletionVisible = completionScreen && completionScreen.style.display === 'flex';
2095 | const isStatsVisible = statsDashboard && statsDashboard.style.display === 'flex';
2096 | const meteoriteInput = document.getElementById('meteorite-typing-input');
2097 | if (meteoriteInput && !isCompletionVisible && !isStatsVisible) {
2098 | meteoriteInput.focus();
2099 | }
2100 | });
2101 | }
2102 |
2103 | // Load the text
2104 | await loadText();
2105 |
2106 | // Extract words for meteorite rain game after text is loaded
2107 | if (config.gameType === 'meteoriteRain' && currentGame instanceof MeteoriteRainGame) {
2108 | if (currentGame.extractWords) {
2109 | currentGame.extractWords();
2110 | }
2111 | }
2112 |
2113 | // Update track dimensions after text is loaded (for racing game)
2114 | if (currentGame && currentGame.updateTrackDimensions) {
2115 | // Wait for layout to settle
2116 | setTimeout(() => {
2117 | if (currentGame && currentGame.updateTrackDimensions) {
2118 | currentGame.updateTrackDimensions();
2119 | }
2120 | }, 100);
2121 | }
2122 |
2123 | // Initialize real-time stats display
2124 | updateRealtimeStats();
2125 |
2126 | // Focus the appropriate input after a short delay
2127 | setTimeout(() => {
2128 | const isCompletionVisible = completionScreen && completionScreen.style.display === 'flex';
2129 | const isStatsVisible = statsDashboard && statsDashboard.style.display === 'flex';
2130 | if (isCompletionVisible || isStatsVisible) return;
2131 |
2132 | if (config.gameType === 'meteoriteRain') {
2133 | const meteoriteInput = document.getElementById('meteorite-typing-input');
2134 | if (meteoriteInput) {
2135 | meteoriteInput.focus();
2136 | }
2137 | } else if (hiddenInput) {
2138 | hiddenInput.focus();
2139 | }
2140 | }, 100);
2141 |
2142 | // Handle window resize for racing game
2143 | let resizeTimeout = null;
2144 | window.addEventListener('resize', () => {
2145 | if (resizeTimeout) {
2146 | clearTimeout(resizeTimeout);
2147 | }
2148 | resizeTimeout = setTimeout(() => {
2149 | if (currentGame && currentGame.updateTrackDimensions) {
2150 | currentGame.updateTrackDimensions();
2151 | }
2152 | }, 250);
2153 | });
2154 | }
2155 |
2156 | // Initialize when DOM is ready
2157 | if (document.readyState === 'loading') {
2158 | document.addEventListener('DOMContentLoaded', initialize);
2159 | } else {
2160 | initialize();
2161 | }
2162 | })();
2163 |
--------------------------------------------------------------------------------