├── client
├── text-to-input.txt
├── config.json
├── stats.txt
├── example-app
│ ├── README.md
│ ├── example-app.css
│ ├── index.html
│ ├── help-content.html
│ └── example-app.js
├── app.js
├── help-content-template.html
├── help-content.html
├── help-modal.js
├── index.html
├── bespoke-template.css
├── typing-simulator.css
└── typing-simulator.js
├── .gitignore
├── .gitmodules
├── vite.config.js
├── package.json
├── .github
└── workflows
│ └── build-release.yml
├── extract_solution.py
├── LICENSE
├── AGENTS.md
├── README.md
├── server.js
└── BESPOKE-TEMPLATE.md
/client/text-to-input.txt:
--------------------------------------------------------------------------------
1 | Test text to input!
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist/
3 | .DS_Store
4 |
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "client/design-system"]
2 | path = client/design-system
3 | url = https://github.com/CodeSignal/learn_bespoke-design-system.git
4 |
--------------------------------------------------------------------------------
/client/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "keyboard": true,
3 | "availableKeys": [],
4 | "showStats": true,
5 | "realTimeStats": ["time", "chars"],
6 | "keyTips": true
7 | }
8 |
--------------------------------------------------------------------------------
/client/stats.txt:
--------------------------------------------------------------------------------
1 | Typing Statistics
2 | ==================
3 |
4 | Total Errors Made: 3
5 | Errors Left (Unfixed): 3
6 | Total Time: 2.81 seconds
7 | Accuracy: 84.21%
8 | Speed: 85.29 words per minute
9 |
10 | Generated: 03/12/2025, 10:19:24
11 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 |
3 | export default defineConfig({
4 | root: './client',
5 | server: {
6 | host: '0.0.0.0',
7 | hmr: true,
8 | allowedHosts: true,
9 | port: 3000,
10 | proxy: {
11 | '/message': {
12 | target: 'http://localhost:3001',
13 | changeOrigin: true
14 | },
15 | '/save-stats': {
16 | target: 'http://localhost:3001',
17 | changeOrigin: true
18 | },
19 | '/ws': {
20 | target: 'ws://localhost:3001',
21 | ws: true,
22 | changeOrigin: true
23 | }
24 | }
25 | },
26 | build: {
27 | outDir: '../dist',
28 | emptyOutDir: true
29 | }
30 | });
31 |
--------------------------------------------------------------------------------
/client/example-app/README.md:
--------------------------------------------------------------------------------
1 | # Example App
2 |
3 | This directory contains an example application that demonstrates how to use the Bespoke Simulation template and its design system components. The example app showcases a simple interactive counter application that uses buttons, inputs, dropdowns, tags, and other design system components to illustrate the template's features and usage patterns. Accessible via the development server at `http://localhost:3000/example-app/index.html`.
4 |
5 | **Important:** This example app is included for reference and testing purposes only. When customizing this template for your own application, you should remove this entire `example-app` directory and replace it with your own application code:
6 |
7 | ```bash
8 | rm -rf client/example-app
9 | ```
10 |
11 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bespoke-template",
3 | "version": "0.0.2",
4 | "description": "Bespoke template with local development server and WebSocket messaging",
5 | "main": "server.js",
6 | "scripts": {
7 | "start": "npm run start:prod",
8 | "start:prod": "IS_PRODUCTION=true node server.js",
9 | "start:dev": "concurrently \"npm run dev:vite\" \"npm run dev:api\"",
10 | "dev:vite": "vite",
11 | "dev:api": "PORT=3001 node server.js",
12 | "build": "vite build"
13 | },
14 | "keywords": [
15 | "bespoke",
16 | "template",
17 | "development",
18 | "server",
19 | "websocket"
20 | ],
21 | "author": "",
22 | "license": "MIT",
23 | "dependencies": {
24 | "ws": "^8.14.2"
25 | },
26 | "devDependencies": {
27 | "concurrently": "^8.2.2",
28 | "vite": "^7.2.4"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/.github/workflows/build-release.yml:
--------------------------------------------------------------------------------
1 | name: Build and Release
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 |
7 | permissions:
8 | contents: write
9 |
10 | jobs:
11 | build-and-release:
12 | runs-on: ubuntu-latest
13 |
14 | steps:
15 | - name: Checkout repository
16 | uses: actions/checkout@v4
17 |
18 | - name: Setup Node.js
19 | uses: actions/setup-node@v4
20 | with:
21 | node-version: '22.13.1'
22 | cache: 'npm'
23 |
24 | - name: Install all dependencies
25 | run: npm ci
26 |
27 | - name: Build project
28 | run: npm run build
29 |
30 | - name: Install production dependencies only
31 | run: |
32 | npm ci --production
33 |
34 | - name: Create release tarball
35 | run: |
36 | tar -czf release.tar.gz dist/ package.json server.js node_modules/
37 |
38 | - name: Upload build artifact (for workflow logs)
39 | uses: actions/upload-artifact@v4
40 | with:
41 | name: dist
42 | path: dist
43 |
44 | - name: Create GitHub Release and upload asset
45 | uses: ncipollo/release-action@v1
46 | with:
47 | token: ${{ secrets.GITHUB_TOKEN }}
48 | tag: v${{ github.run_number }}
49 | name: Release ${{ github.run_number }}
50 | body: |
51 | Latest build from main branch.
52 | artifacts: release.tar.gz
53 | allowUpdates: false
54 | draft: false
55 | prerelease: false
56 | makeLatest: true
57 |
--------------------------------------------------------------------------------
/client/app.js:
--------------------------------------------------------------------------------
1 | // app.js
2 | (function() {
3 | const status = document.getElementById('status');
4 | let websocket = null;
5 |
6 | function setStatus(msg) {
7 | status.textContent = msg;
8 | }
9 |
10 | // Initialize WebSocket connection
11 | function initializeWebSocket() {
12 | const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
13 | const host = window.location.host;
14 | const wsUrl = `${protocol}//${host}/ws`;
15 |
16 | try {
17 | websocket = new WebSocket(wsUrl);
18 |
19 | websocket.onopen = function(event) {
20 | console.log('WebSocket connected');
21 | setStatus('Ready (WebSocket connected)');
22 | };
23 |
24 | websocket.onmessage = function(event) {
25 | try {
26 | const data = JSON.parse(event.data);
27 | if (data.type === 'message' && data.message) {
28 | alert(data.message);
29 | }
30 | } catch (error) {
31 | console.error('Error parsing WebSocket message:', error);
32 | }
33 | };
34 |
35 | websocket.onclose = function(event) {
36 | console.log('WebSocket disconnected');
37 | setStatus('Ready (WebSocket disconnected)');
38 |
39 | // Attempt to reconnect after 3 seconds
40 | setTimeout(() => {
41 | console.log('Attempting to reconnect WebSocket...');
42 | initializeWebSocket();
43 | }, 3000);
44 | };
45 |
46 | websocket.onerror = function(error) {
47 | console.error('WebSocket error:', error);
48 | setStatus('Ready (WebSocket error)');
49 | };
50 |
51 | } catch (error) {
52 | console.error('Failed to create WebSocket connection:', error);
53 | setStatus('Ready (WebSocket unavailable)');
54 | }
55 | }
56 |
57 | // Load help content and initialize modal
58 | async function initializeHelpModal() {
59 | try {
60 | const response = await fetch('./help-content.html');
61 | const helpContent = await response.text();
62 |
63 | // Initialize help modal with actual content
64 | HelpModal.init({
65 | triggerSelector: '#btn-help',
66 | content: helpContent,
67 | theme: 'auto'
68 | });
69 |
70 | setStatus('Ready');
71 | } catch (error) {
72 | console.error('Failed to load help content:', error);
73 | // Fallback to placeholder content
74 | HelpModal.init({
75 | triggerSelector: '#btn-help',
76 | content: '
Help content could not be loaded. Please check that help-content.html exists.
',
77 | theme: 'auto'
78 | });
79 | setStatus('Ready (help content unavailable)');
80 | }
81 | }
82 |
83 | // Initialize both help modal and WebSocket when DOM is ready
84 | function initialize() {
85 | initializeHelpModal();
86 | initializeWebSocket();
87 | }
88 |
89 | if (document.readyState === 'loading') {
90 | document.addEventListener('DOMContentLoaded', initialize);
91 | } else {
92 | initialize();
93 | }
94 | })();
95 |
--------------------------------------------------------------------------------
/extract_solution.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | """
3 | Extract and print typing statistics from stats.txt file.
4 | """
5 |
6 | import os
7 | import re
8 | from pathlib import Path
9 |
10 |
11 | def extract_stats():
12 | """Read stats.txt and print all statistics to console."""
13 | # Get the project root directory (where this script is located)
14 | script_dir = Path(__file__).parent
15 | stats_file = script_dir / 'client' / 'stats.txt'
16 |
17 | # Check if stats file exists
18 | if not stats_file.exists():
19 | print(f"Error: stats.txt not found at {stats_file}")
20 | return
21 |
22 | # Read the stats file
23 | try:
24 | with open(stats_file, 'r', encoding='utf-8') as f:
25 | content = f.read()
26 | except Exception as e:
27 | print(f"Error reading stats.txt: {e}")
28 | return
29 |
30 | # Parse and extract statistics
31 | stats = {}
32 |
33 | # Extract Total Errors Made
34 | match = re.search(r'Total Errors Made:\s*(\d+)', content)
35 | if match:
36 | stats['total_errors'] = int(match.group(1))
37 |
38 | # Extract Errors Left (Unfixed)
39 | match = re.search(r'Errors Left \(Unfixed\):\s*(\d+)', content)
40 | if match:
41 | stats['errors_left'] = int(match.group(1))
42 |
43 | # Extract Total Time
44 | match = re.search(r'Total Time:\s*([\d.]+)\s*seconds', content)
45 | if match:
46 | stats['total_time'] = float(match.group(1))
47 |
48 | # Extract Accuracy
49 | match = re.search(r'Accuracy:\s*([\d.]+)%', content)
50 | if match:
51 | stats['accuracy'] = float(match.group(1))
52 |
53 | # Extract Speed (WPM)
54 | match = re.search(r'Speed:\s*([\d.]+)\s*words per minute', content)
55 | if match:
56 | stats['speed'] = float(match.group(1))
57 |
58 | # Extract Generated timestamp
59 | match = re.search(r'Generated:\s*(.+)', content)
60 | if match:
61 | stats['generated'] = match.group(1).strip()
62 |
63 | # Print all statistics
64 | print("Typing Statistics")
65 | print("=" * 50)
66 | print()
67 |
68 | if 'total_errors' in stats:
69 | print(f"Total Errors Made: {stats['total_errors']}")
70 |
71 | if 'errors_left' in stats:
72 | print(f"Errors Left (Unfixed): {stats['errors_left']}")
73 |
74 | if 'total_time' in stats:
75 | time_value = stats['total_time']
76 | if time_value < 60:
77 | print(f"Total Time: {time_value:.2f} seconds")
78 | else:
79 | minutes = int(time_value // 60)
80 | seconds = time_value % 60
81 | print(f"Total Time: {minutes}m {seconds:.2f}s")
82 |
83 | if 'accuracy' in stats:
84 | print(f"Accuracy: {stats['accuracy']:.2f}%")
85 |
86 | if 'speed' in stats:
87 | print(f"Speed: {stats['speed']:.2f} words per minute")
88 |
89 | if 'generated' in stats:
90 | print(f"Generated: {stats['generated']}")
91 |
92 | print()
93 |
94 |
95 | if __name__ == '__main__':
96 | extract_stats()
97 |
--------------------------------------------------------------------------------
/client/example-app/example-app.css:
--------------------------------------------------------------------------------
1 | /* Example App Styles - Interactive Component Showcase */
2 |
3 | .bespoke .sidebar {
4 | padding: var(--UI-Spacing-spacing-xl);
5 | overflow-y: auto;
6 | }
7 |
8 | .bespoke .sidebar-section {
9 | display: flex;
10 | flex-direction: column;
11 | gap: var(--UI-Spacing-spacing-xl);
12 | }
13 |
14 | .bespoke .sidebar-section h2 {
15 | font-size: var(--Fonts-Headlines-sm);
16 | font-weight: 600;
17 | color: var(--Colors-Text-Body-Strongest);
18 | margin: 0 0 var(--UI-Spacing-spacing-ml) 0;
19 | }
20 |
21 | .bespoke .control-group {
22 | display: flex;
23 | flex-direction: column;
24 | gap: var(--UI-Spacing-spacing-s);
25 | }
26 |
27 | .bespoke .control-group label {
28 | font-size: var(--Fonts-Body-Default-sm);
29 | font-weight: 500;
30 | color: var(--Colors-Text-Body-Strong);
31 | }
32 |
33 | .bespoke .control-group .button {
34 | width: 100%;
35 | }
36 |
37 | .bespoke .control-group-buttons {
38 | flex-direction: row;
39 | gap: var(--UI-Spacing-spacing-s);
40 | }
41 |
42 | .bespoke .control-group-buttons .button {
43 | flex: 1;
44 | }
45 |
46 | .bespoke .content-area {
47 | overflow-y: auto;
48 | padding: var(--UI-Spacing-spacing-xl);
49 | }
50 |
51 | .bespoke .display-container {
52 | max-width: 800px;
53 | margin: 0 auto;
54 | display: flex;
55 | flex-direction: column;
56 | gap: var(--UI-Spacing-spacing-xl);
57 | }
58 |
59 | .bespoke .counter-display {
60 | flex-direction: column;
61 | align-items: center;
62 | justify-content: center;
63 | padding: var(--UI-Spacing-spacing-2xl);
64 | text-align: center;
65 | }
66 |
67 | .bespoke .counter-display h2 {
68 | font-size: var(--Fonts-Headlines-sm);
69 | font-weight: 600;
70 | color: var(--Colors-Text-Body-Strong);
71 | margin: 0 0 var(--UI-Spacing-spacing-ml) 0;
72 | text-align: center;
73 | }
74 |
75 | .bespoke .counter-value {
76 | font-size: 4rem;
77 | font-weight: 700;
78 | font-family: var(--heading-family);
79 | color: var(--Colors-Text-Body-Strongest);
80 | line-height: 1;
81 | text-align: center;
82 | }
83 |
84 | .bespoke .tags-container {
85 | display: flex;
86 | gap: var(--UI-Spacing-spacing-ml);
87 | justify-content: center;
88 | flex-wrap: wrap;
89 | }
90 |
91 | .bespoke .settings-display {
92 | flex-direction: column;
93 | align-items: flex-start;
94 | justify-content: flex-start;
95 | padding: var(--UI-Spacing-spacing-xl);
96 | }
97 |
98 | .bespoke .settings-display h3 {
99 | font-size: var(--Fonts-Headlines-xs);
100 | font-weight: 600;
101 | color: var(--Colors-Text-Body-Strong);
102 | margin: 0 0 var(--UI-Spacing-spacing-ml) 0;
103 | width: 100%;
104 | }
105 |
106 | .bespoke .setting-item {
107 | font-size: var(--Fonts-Body-Default-sm);
108 | color: var(--Colors-Text-Body-Default);
109 | margin-bottom: var(--UI-Spacing-spacing-s);
110 | width: 100%;
111 | }
112 |
113 | .bespoke .setting-item:last-child {
114 | margin-bottom: 0;
115 | }
116 |
117 | .bespoke .setting-item strong {
118 | color: var(--Colors-Text-Body-Strong);
119 | margin-right: var(--UI-Spacing-spacing-xs);
120 | }
121 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Elastic License 2.0
2 |
3 | URL: https://www.elastic.co/licensing/elastic-license
4 |
5 | ## Acceptance
6 |
7 | By using the software, you agree to all of the terms and conditions below.
8 |
9 | ## Copyright License
10 |
11 | The licensor grants you a non-exclusive, royalty-free, worldwide,
12 | non-sublicensable, non-transferable license to use, copy, distribute, make
13 | available, and prepare derivative works of the software, in each case subject to
14 | the limitations and conditions below.
15 |
16 | ## Limitations
17 |
18 | You may not provide the software to third parties as a hosted or managed
19 | service, where the service provides users with access to any substantial set of
20 | the features or functionality of the software.
21 |
22 | You may not move, change, disable, or circumvent the license key functionality
23 | in the software, and you may not remove or obscure any functionality in the
24 | software that is protected by the license key.
25 |
26 | You may not alter, remove, or obscure any licensing, copyright, or other notices
27 | of the licensor in the software. Any use of the licensor’s trademarks is subject
28 | to applicable law.
29 |
30 | ## Patents
31 |
32 | The licensor grants you a license, under any patent claims the licensor can
33 | license, or becomes able to license, to make, have made, use, sell, offer for
34 | sale, import and have imported the software, in each case subject to the
35 | limitations and conditions in this license. This license does not cover any
36 | patent claims that you cause to be infringed by modifications or additions to
37 | the software. If you or your company make any written claim that the software
38 | infringes or contributes to infringement of any patent, your patent license for
39 | the software granted under these terms ends immediately. If your company makes
40 | such a claim, your patent license ends immediately for work on behalf of your
41 | company.
42 |
43 | ## Notices
44 |
45 | You must ensure that anyone who gets a copy of any part of the software from you
46 | also gets a copy of these terms.
47 |
48 | If you modify the software, you must include in any modified copies of the
49 | software prominent notices stating that you have modified the software.
50 |
51 | ## No Other Rights
52 |
53 | These terms do not imply any licenses other than those expressly granted in
54 | these terms.
55 |
56 | ## Termination
57 |
58 | If you use the software in violation of these terms, such use is not licensed,
59 | and your licenses will automatically terminate. If the licensor provides you
60 | with a notice of your violation, and you cease all violation of this license no
61 | later than 30 days after you receive that notice, your licenses will be
62 | reinstated retroactively. However, if you violate these terms after such
63 | reinstatement, any additional violation of these terms will cause your licenses
64 | to terminate automatically and permanently.
65 |
66 | ## No Liability
67 |
68 | *As far as the law allows, the software comes as is, without any warranty or
69 | condition, and the licensor will not be liable to you for any damages arising
70 | out of these terms or the use or nature of the software, under any kind of
71 | legal claim.*
72 |
73 | ## Definitions
74 |
75 | The **licensor** is the entity offering these terms, and the **software** is the
76 | software the licensor makes available under these terms, including any portion
77 | of it.
78 |
79 | **you** refers to the individual or entity agreeing to these terms.
80 |
81 | **your company** is any legal entity, sole proprietorship, or other kind of
82 | organization that you work for, plus all organizations that have control over,
83 | are under the control of, or are under common control with that
84 | organization. **control** means ownership of substantially all the assets of an
85 | entity, or the power to direct its management and policies by vote, contract, or
86 | otherwise. Control can be direct or indirect.
87 |
88 | **your licenses** are all the licenses granted to you for the software under
89 | these terms.
90 |
91 | **use** means anything you do with the software requiring one of your licenses.
92 |
93 | **trademark** means trademarks, service marks, and similar rights.
94 |
--------------------------------------------------------------------------------
/AGENTS.md:
--------------------------------------------------------------------------------
1 | # Repository Guidelines
2 |
3 | This repository contains a template for building embedded applications using
4 | the Bespoke Simulation framework. For complete template documentation, see
5 | [BESPOKE-TEMPLATE.md](./BESPOKE-TEMPLATE.md).
6 |
7 | ## Overview
8 |
9 | This template provides:
10 | - CodeSignal Design System integration
11 | - Consistent layout components (header, sidebar, main content area)
12 | - Help modal system
13 | - Local development server with WebSocket support
14 | - Standardized file structure and naming conventions
15 |
16 | ## Quick Start
17 |
18 | 1. **Customize the HTML template** (`client/index.html`):
19 | - Replace `` with your page title
20 | - Replace `` with your app name
21 | - Add your main content at ``
22 | - Add app-specific CSS links at ``
23 | - Add app-specific JavaScript at ``
24 |
25 | 2. **Create your application files**:
26 | - App-specific CSS (e.g., `my-app.css`)
27 | - App-specific JavaScript (e.g., `my-app.js`)
28 | - Help content (based on `help-content-template.html`)
29 |
30 | 3. **Start the development server**:
31 | ```bash
32 | npm start
33 | ```
34 | Server runs on `http://localhost:3000`
35 |
36 | ## Key Conventions
37 |
38 | ### Status Messages
39 |
40 | Use these exact status messages for consistency:
41 |
42 | - "Ready" - Application loaded successfully
43 | - "Loading..." - Data is being loaded
44 | - "Saving..." - Data is being saved
45 | - "Changes saved" - Auto-save completed successfully
46 | - "Save failed (will retry)" - Server save failed, will retry
47 | - "Failed to load data" - Data loading failed
48 | - "Auto-save initialized" - Auto-save system started
49 |
50 | ### File Naming
51 |
52 | - CSS files: kebab-case (e.g., `my-app.css`)
53 | - JavaScript files: kebab-case (e.g., `my-app.js`)
54 | - Data files: kebab-case (e.g., `solution.json`)
55 | - Image files: kebab-case (e.g., `overview.png`)
56 |
57 | ### Error Handling
58 |
59 | - Wrap all async operations in try-catch blocks
60 | - Provide meaningful error messages to users
61 | - Log errors to console for debugging
62 | - Implement retry logic for network operations
63 | - Handle localStorage quota exceeded errors
64 | - Validate data before saving operations
65 |
66 | ## Development Workflow
67 |
68 | ### Build and Test
69 |
70 | ```bash
71 | # Start development server
72 | npm start
73 |
74 | # Development mode (same as start)
75 | npm run dev
76 | ```
77 |
78 | ### WebSocket Messaging
79 |
80 | The server provides a `POST /message` endpoint for real-time messaging:
81 |
82 | ```bash
83 | curl -X POST http://localhost:3000/message \
84 | -H "Content-Type: application/json" \
85 | -d '{"message": "Your message here"}'
86 | ```
87 |
88 | This sends alerts to connected clients. Requires `ws` package:
89 | ```bash
90 | npm install
91 | ```
92 |
93 | ## Template Documentation
94 |
95 | For detailed information about:
96 | - Design System usage and components
97 | - CSS implementation guidelines
98 | - JavaScript API (HelpModal, status management)
99 | - Component reference and examples
100 | - Customization options
101 |
102 | See [BESPOKE-TEMPLATE.md](./BESPOKE-TEMPLATE.md).
103 |
104 | ## Project Structure
105 |
106 | ```
107 | client/
108 | ├── index.html # Main HTML template
109 | ├── app.js # Application logic
110 | ├── bespoke-template.css # Template-specific styles
111 | ├── help-modal.js # Help modal system
112 | ├── help-content-template.html # Help content template
113 | └── design-system/ # CodeSignal Design System
114 | ├── colors/
115 | ├── spacing/
116 | ├── typography/
117 | └── components/
118 | server.js # Development server
119 | ```
120 |
121 | ## Notes for AI Agents
122 |
123 | When working on applications built with this template:
124 |
125 | 1. **Always reference BESPOKE-TEMPLATE.md** for template-specific
126 | implementation details
127 | 2. **Follow the conventions** listed above for status messages and file naming
128 | 3. **Use Design System components** directly - see BESPOKE-TEMPLATE.md for
129 | component classes and usage
130 | 4. **Maintain consistency** with the template's structure and patterns
131 | 5. **Keep guidelines up to date** by editing this AGENTS.md file as the codebase evolves
--------------------------------------------------------------------------------
/client/example-app/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Design System Component Showcase
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
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/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 |
36 |
37 |
38 |
39 |
40 |
41 |
52 |
53 |
Completed
54 |
Great job! You've finished typing the text.
55 |
Start Over
56 |
57 |
58 |
62 |
63 |
64 |
65 | 0
66 |
67 |
WPM
68 |
69 |
70 |
71 | 0%
72 |
73 |
Accuracy
74 |
75 |
76 |
77 | 0s
78 |
79 |
Total time
80 |
81 |
82 |
83 | 0
84 |
85 |
Total errors
86 |
87 |
88 |
89 | 0
90 |
91 |
Errors left
92 |
93 |
94 |
97 |
98 |
107 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
126 |
127 |
128 |
--------------------------------------------------------------------------------
/client/example-app/example-app.js:
--------------------------------------------------------------------------------
1 | // example-app.js
2 | // Interactive Component Showcase Application
3 |
4 | (function() {
5 | const status = document.getElementById('status');
6 |
7 | // App state
8 | let counterValue = 0;
9 | let incrementAmount = 1;
10 | let counterLabel = 'Counter';
11 | let dropdownInstance = null;
12 |
13 | function setStatus(msg) {
14 | if (status) {
15 | status.textContent = msg;
16 | }
17 | }
18 |
19 | // Update counter display
20 | function updateCounterDisplay() {
21 | const counterDisplay = document.getElementById('counter-value');
22 | const labelDisplay = document.getElementById('display-label');
23 | const labelValueDisplay = document.getElementById('display-label-value');
24 | const incrementValueDisplay = document.getElementById('display-increment-value');
25 |
26 | if (counterDisplay) {
27 | counterDisplay.textContent = counterValue;
28 | }
29 |
30 | if (labelDisplay) {
31 | labelDisplay.textContent = counterLabel;
32 | }
33 |
34 | if (labelValueDisplay) {
35 | labelValueDisplay.textContent = counterLabel;
36 | }
37 |
38 | if (incrementValueDisplay) {
39 | incrementValueDisplay.textContent = incrementAmount;
40 | }
41 |
42 | // Update status tags
43 | updateStatusTags();
44 | }
45 |
46 | // Update status tags based on counter value
47 | function updateStatusTags() {
48 | const primaryTag = document.getElementById('status-tag-primary');
49 | const positiveTag = document.getElementById('status-tag-positive');
50 | const negativeTag = document.getElementById('status-tag-negative');
51 |
52 | if (counterValue > 0) {
53 | if (primaryTag) primaryTag.style.display = 'none';
54 | if (positiveTag) positiveTag.style.display = 'inline-block';
55 | if (negativeTag) negativeTag.style.display = 'none';
56 | } else if (counterValue < 0) {
57 | if (primaryTag) primaryTag.style.display = 'none';
58 | if (positiveTag) positiveTag.style.display = 'none';
59 | if (negativeTag) negativeTag.style.display = 'inline-block';
60 | } else {
61 | if (primaryTag) primaryTag.style.display = 'inline-block';
62 | if (positiveTag) positiveTag.style.display = 'none';
63 | if (negativeTag) negativeTag.style.display = 'none';
64 | }
65 | }
66 |
67 | // Increment counter
68 | function incrementCounter() {
69 | counterValue += incrementAmount;
70 | updateCounterDisplay();
71 | setStatus('Counter incremented');
72 | }
73 |
74 | // Decrement counter
75 | function decrementCounter() {
76 | counterValue -= incrementAmount;
77 | updateCounterDisplay();
78 | setStatus('Counter decremented');
79 | }
80 |
81 | // Reset counter
82 | function resetCounter() {
83 | counterValue = 0;
84 | updateCounterDisplay();
85 | setStatus('Counter reset');
86 | }
87 |
88 | // Initialize dropdown component
89 | function initializeDropdown() {
90 | if (typeof window.Dropdown === 'undefined') {
91 | console.error('Dropdown class not found. Make sure dropdown.js is loaded.');
92 | return;
93 | }
94 |
95 | const dropdownItems = [
96 | { value: '1', label: '1' },
97 | { value: '5', label: '5' },
98 | { value: '10', label: '10' },
99 | { value: '25', label: '25' }
100 | ];
101 |
102 | try {
103 | dropdownInstance = new window.Dropdown('#increment-dropdown', {
104 | items: dropdownItems,
105 | selectedValue: '1',
106 | placeholder: 'Select increment amount',
107 | onSelect: (value) => {
108 | incrementAmount = parseInt(value, 10);
109 | updateCounterDisplay();
110 | setStatus(`Increment amount set to ${incrementAmount}`);
111 | }
112 | });
113 | } catch (error) {
114 | console.error('Error initializing dropdown:', error);
115 | }
116 | }
117 |
118 | // Initialize event listeners
119 | function initializeEventListeners() {
120 | // Sidebar controls
121 | const btnIncrement = document.getElementById('btn-increment');
122 | const btnDecrement = document.getElementById('btn-decrement');
123 | const btnReset = document.getElementById('btn-reset');
124 | const counterLabelInput = document.getElementById('counter-label');
125 |
126 | // Increment button
127 | if (btnIncrement) {
128 | btnIncrement.addEventListener('click', incrementCounter);
129 | }
130 |
131 | // Decrement button
132 | if (btnDecrement) {
133 | btnDecrement.addEventListener('click', decrementCounter);
134 | }
135 |
136 | // Reset button
137 | if (btnReset) {
138 | btnReset.addEventListener('click', resetCounter);
139 | }
140 |
141 | // Label input
142 | if (counterLabelInput) {
143 | counterLabelInput.addEventListener('input', (e) => {
144 | counterLabel = e.target.value || 'Counter';
145 | updateCounterDisplay();
146 | setStatus('Label updated');
147 | });
148 | }
149 | }
150 |
151 | // Initialize help modal
152 | async function initializeHelpModal() {
153 | try {
154 | const response = await fetch('./help-content.html');
155 | const helpContent = await response.text();
156 |
157 | if (typeof HelpModal !== 'undefined') {
158 | HelpModal.init({
159 | triggerSelector: '#btn-help',
160 | content: helpContent,
161 | theme: 'auto'
162 | });
163 | } else {
164 | console.error('HelpModal not found. Make sure help-modal.js is loaded.');
165 | }
166 | } catch (error) {
167 | console.error('Failed to load help content:', error);
168 | if (typeof HelpModal !== 'undefined') {
169 | HelpModal.init({
170 | triggerSelector: '#btn-help',
171 | content: 'Help content could not be loaded. Please check that help-content.html exists.
',
172 | theme: 'auto'
173 | });
174 | }
175 | }
176 | }
177 |
178 | // Initialize everything when DOM is ready
179 | function initialize() {
180 | setStatus('Loading...');
181 |
182 | // Initialize event listeners
183 | initializeEventListeners();
184 |
185 | // Initialize help modal
186 | initializeHelpModal();
187 |
188 | // Initialize dropdown after a short delay to ensure Dropdown class is loaded
189 | setTimeout(() => {
190 | initializeDropdown();
191 | updateCounterDisplay();
192 | setStatus('Ready');
193 | }, 100);
194 | }
195 |
196 | if (document.readyState === 'loading') {
197 | document.addEventListener('DOMContentLoaded', initialize);
198 | } else {
199 | initialize();
200 | }
201 | })();
202 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Bespoke Simulation Template
2 |
3 | This directory contains a template for creating embedded applications that share a consistent design system and user experience.
4 |
5 | ## Components
6 |
7 | ### 1. Design System Integration
8 | This template uses the CodeSignal Design System located in `client/design-system/`:
9 | - **Foundations**: Colors, spacing, typography tokens
10 | - **Components**: Buttons, boxes, inputs, dropdowns, tags
11 | - Light and dark theme support (automatic)
12 | - See the [design system repository](https://github.com/CodeSignal/learn_bespoke-design-system) for full documentation
13 |
14 | ### 2. `client/bespoke-template.css`
15 | Template-specific CSS providing:
16 | - Layout components (header, sidebar, main-layout)
17 | - Utility classes (row, spacer, status)
18 | - Temporary components (modals, form elements) - will be replaced when design system adds them
19 |
20 | ### 3. `client/index.html`
21 | A base HTML template that includes:
22 | - Navigation header with app name and help button
23 | - Main layout structure (sidebar + content area)
24 | - Help modal integration
25 | - Proper CSS and JavaScript loading
26 |
27 | ### 4. `client/help-modal.js`
28 | A dependency-free JavaScript module for the help modal system:
29 | - Consistent modal behavior across all apps
30 | - Keyboard navigation (ESC to close)
31 | - Focus management
32 | - Custom event system
33 |
34 | ### 5. `client/help-content-template.html`
35 | A template for creating consistent help content:
36 | - Table of contents navigation
37 | - Standardized section structure
38 | - FAQ with collapsible details
39 | - Image integration guidelines
40 |
41 | ## Usage Instructions
42 |
43 | ### Setting Up a New Application
44 |
45 | 1. **Clone the repository**
46 | 2. **Ensure the design-system submodule is initialized**:
47 | ```bash
48 | git submodule update --init --recursive
49 | ```
50 |
51 | 3. **Customize the HTML template** by replacing placeholders:
52 | - `` - Your application title
53 | - `` - Your application name (appears in header)
54 | - `` - Any additional header elements
55 | - `` - Your main content area
56 | - `` - Links to your app-specific CSS files
57 | - `` - Links to your app-specific JavaScript files
58 |
59 | 3. **Use Design System Components**
60 | The template uses design system components directly. Use these classes:
61 | - Buttons: `button button-primary`, `button button-secondary`, `button button-danger`, `button button-text`
62 | - Boxes/Cards: `box card` for card containers
63 | - Inputs: Add `input` class to input elements: ` `
64 |
65 | 4. **Implement your application logic**. You can use Cursor or other agents for it. There is a file called `AGENTS.md` that contains context LLM can use.
66 | 5. **Customise your help content** using the help content template
67 | 3. **Use Design System Components**
68 | The template uses design system components directly. Use these classes:
69 | - Buttons: `button button-primary`, `button button-secondary`, `button button-danger`, `button button-text`
70 | - Boxes/Cards: `box card` for card containers
71 | - Inputs: Add `input` class to input elements: ` `
72 |
73 | 4. **Implement your application logic**. You can use Cursor or other agents for it. There is a file called `AGENTS.md` that contains context LLM can use.
74 | 5. **Customise your help content** using the help content template
75 |
76 | ### Customizing Help Content
77 |
78 | Use the `help-content-template.html` as a starting point:
79 |
80 | 1. **Replace placeholders** like `` with your actual content
81 | 2. **Add sections** as needed for your application
82 | 3. **Include images** by placing them in a `help/img/` directory
83 | 4. **Use the provided structure** for consistency across applications
84 |
85 |
86 | ### Help Modal API
87 |
88 | The `HelpModal` class provides several methods:
89 |
90 | ```javascript
91 | // Initialize
92 | const modal = HelpModal.init({
93 | triggerSelector: '#btn-help',
94 | content: helpContent,
95 | theme: 'auto'
96 | });
97 |
98 | // Update content dynamically
99 | modal.updateContent(newHelpContent);
100 |
101 | // Destroy the modal
102 | modal.destroy();
103 | ```
104 |
105 | ## Server
106 |
107 | This template includes a local development server (`server.js`) that provides:
108 | - Static file serving for your application
109 | - WebSocket support for real-time messaging
110 | - A REST API for triggering client-side alerts
111 |
112 | ### Starting the Server
113 |
114 | ```bash
115 | # Local development
116 | npm run start:dev # Vite + API for local development
117 | # Production
118 | npm run build # Create production build in dist/
119 | npm run start:prod # Serve built assets from dist/
120 | ```
121 |
122 |
123 | ### Environment Variables
124 |
125 | The server supports the following environment variables:
126 |
127 | - **`PORT`** - Server port number
128 | - Development: Can be set to any port (e.g., `PORT=3001`), defaulting to `3000`
129 | - Production: Ignored (always `3000` when `IS_PRODUCTION=true`)
130 |
131 | - **`IS_PRODUCTION`** - Enables production mode
132 | - Set to `'true'` to enable production mode
133 | - When enabled:
134 | - Server serves static files from `dist/` directory
135 | - Port is forced to `3000`
136 | - Requires `dist/` directory to exist (throws error if missing)
137 |
138 |
139 | ### Vite Build System
140 |
141 | This project uses [Vite](https://vitejs.dev/) as the build tool for fast development and optimized production builds.
142 |
143 | #### Build Process
144 |
145 | Running `npm run build` executes `vite build`, which:
146 | - Reads source files from the `client/` directory (configured in `vite.config.js`)
147 | - Processes and bundles JavaScript, CSS, and other assets
148 | - Outputs optimized production files to the `dist/` directory
149 | - Generates hashed filenames for cache busting
150 |
151 | ### WebSocket Messaging API
152 |
153 | The server provides a `POST /message` endpoint that allows you to send real-time messages to connected clients. This can be used to signal changes in the client during events like "Run" or "Submit". When a message is sent, the preview window with the application open will display an alert with the message.
154 |
155 | It uses the `ws` package, so if you want to use it, install the packages (but this is optional).
156 |
157 | ```
158 | npm install
159 | ```
160 |
161 | #### Endpoint: `POST /message`
162 |
163 | **Request Format:**
164 | ```json
165 | {
166 | "message": "Your message here"
167 | }
168 | ```
169 |
170 | **Example using curl:**
171 | ```bash
172 | curl -X POST http://localhost:3000/message \
173 | -H "Content-Type: application/json" \
174 | -d '{"message": "Hello from the server!"}'
175 | ```
176 |
177 | ## CI/CD and Automated Releases
178 |
179 | This template includes a GitHub Actions workflow (`.github/workflows/build-release.yml`) that automatically builds and releases your application when you push to the `main` branch.
180 |
181 | ### How It Works
182 |
183 | When you push to `main`, the workflow will:
184 |
185 | 1. **Build the project** - Runs `npm run build` to create production assets in `dist/`
186 | 2. **Create a release tarball** - Packages `dist/`, `package.json`, `server.js`, and production `node_modules/` into `release.tar.gz`
187 | 3. **Create a GitHub Release** - Automatically creates a new release tagged as `v{run_number}` with the tarball attached
188 |
189 | ### Release Contents
190 |
191 | The release tarball (`release.tar.gz`) contains everything needed to deploy the application:
192 | - `dist/` - Built production assets
193 | - `package.json` - Project dependencies and scripts
194 | - `server.js` - Production server
195 | - `node_modules/` - Production dependencies only
196 |
197 | ### Using Releases
198 |
199 | To deploy a release:
200 |
201 | 1. Download `release.tar.gz` from the latest GitHub Release (e.g. with `wget`)
202 | 2. Extract (and remove) the tarball: `tar -xzf release.tar.gz && rm release.tar.gz`
203 | 3. Start the production server: `npm run start:prod`
204 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | const http = require('http');
2 | const fs = require('fs');
3 | const path = require('path');
4 | const url = require('url');
5 |
6 | // Try to load WebSocket module, fallback if not available
7 | let WebSocket = null;
8 | let isWebSocketAvailable = false;
9 | try {
10 | WebSocket = require('ws');
11 | isWebSocketAvailable = true;
12 | console.log('WebSocket support enabled');
13 | } catch (error) {
14 | console.log('WebSocket support disabled (ws package not installed)');
15 | console.log('Install with: npm install ws');
16 | }
17 |
18 | const DIST_DIR = path.join(__dirname, 'dist');
19 | // Check if IS_PRODUCTION is set to true
20 | const isProduction = process.env.IS_PRODUCTION === 'true';
21 | // In production mode, dist directory must exist
22 | if (isProduction && !fs.existsSync(DIST_DIR)) {
23 | throw new Error(`Production mode enabled but dist directory does not exist: ${DIST_DIR}`);
24 | }
25 | // Force port 3000 in production, otherwise use PORT environment variable or default to 3000
26 | const PORT = isProduction ? 3000 : (process.env.PORT || 3000);
27 |
28 | // Track connected WebSocket clients
29 | const wsClients = new Set();
30 |
31 | // MIME types for different file extensions
32 | const mimeTypes = {
33 | '.html': 'text/html',
34 | '.js': 'text/javascript',
35 | '.css': 'text/css',
36 | '.json': 'application/json',
37 | '.png': 'image/png',
38 | '.jpg': 'image/jpeg',
39 | '.jpeg': 'image/jpeg',
40 | '.gif': 'image/gif',
41 | '.svg': 'image/svg+xml',
42 | '.ico': 'image/x-icon',
43 | '.woff': 'font/woff',
44 | '.woff2': 'font/woff2',
45 | '.ttf': 'font/ttf',
46 | '.eot': 'application/vnd.ms-fontobject'
47 | };
48 |
49 | // Get MIME type based on file extension
50 | function getMimeType(filePath) {
51 | const ext = path.extname(filePath).toLowerCase();
52 | return mimeTypes[ext] || 'text/plain';
53 | }
54 |
55 | // Serve static files
56 | function serveFile(filePath, res) {
57 | fs.readFile(filePath, (err, data) => {
58 | if (err) {
59 | res.writeHead(404, { 'Content-Type': 'text/plain' });
60 | res.end('File not found');
61 | return;
62 | }
63 |
64 | const mimeType = getMimeType(filePath);
65 | res.writeHead(200, { 'Content-Type': mimeType });
66 | res.end(data);
67 | });
68 | }
69 |
70 | // Handle POST requests
71 | function handlePostRequest(req, res, parsedUrl) {
72 | if (parsedUrl.pathname === '/message') {
73 | let body = '';
74 |
75 | req.on('data', chunk => {
76 | body += chunk.toString();
77 | });
78 |
79 | req.on('end', () => {
80 | try {
81 | const data = JSON.parse(body);
82 | const message = data.message;
83 |
84 | if (!message) {
85 | res.writeHead(400, { 'Content-Type': 'application/json' });
86 | res.end(JSON.stringify({ error: 'Message is required' }));
87 | return;
88 | }
89 |
90 | // Check if WebSocket is available
91 | if (!isWebSocketAvailable) {
92 | res.writeHead(503, { 'Content-Type': 'application/json' });
93 | res.end(JSON.stringify({
94 | error: 'WebSocket functionality not available',
95 | details: 'Install the ws package with: npm install ws'
96 | }));
97 | return;
98 | }
99 |
100 | // Broadcast message to all connected WebSocket clients
101 | wsClients.forEach(client => {
102 | if (client.readyState === WebSocket.OPEN) {
103 | client.send(JSON.stringify({ type: 'message', message: message }));
104 | }
105 | });
106 |
107 | res.writeHead(200, { 'Content-Type': 'application/json' });
108 | res.end(JSON.stringify({ success: true, clientCount: wsClients.size }));
109 |
110 | } catch (error) {
111 | res.writeHead(400, { 'Content-Type': 'application/json' });
112 | res.end(JSON.stringify({ error: 'Invalid JSON' }));
113 | }
114 | });
115 | } else if (parsedUrl.pathname === '/save-stats') {
116 | let body = '';
117 |
118 | req.on('data', chunk => {
119 | body += chunk.toString();
120 | });
121 |
122 | req.on('end', () => {
123 | try {
124 | const clientDir = path.join(__dirname, 'client');
125 | const statsPath = path.join(clientDir, 'stats.txt');
126 |
127 | // Ensure client directory exists
128 | if (!fs.existsSync(clientDir)) {
129 | fs.mkdirSync(clientDir, { recursive: true });
130 | }
131 |
132 | // Write stats to file
133 | fs.writeFileSync(statsPath, body, 'utf8');
134 |
135 | res.writeHead(200, { 'Content-Type': 'application/json' });
136 | res.end(JSON.stringify({ success: true, message: 'Statistics saved' }));
137 |
138 | } catch (error) {
139 | console.error('Error saving statistics:', error);
140 | res.writeHead(500, { 'Content-Type': 'application/json' });
141 | res.end(JSON.stringify({ error: 'Failed to save statistics' }));
142 | }
143 | });
144 | } else {
145 | res.writeHead(404, { 'Content-Type': 'text/plain' });
146 | res.end('Not found');
147 | }
148 | }
149 |
150 | // Create HTTP server
151 | const server = http.createServer((req, res) => {
152 | const parsedUrl = url.parse(req.url, true);
153 | let pathName = parsedUrl.pathname === '/' ? '/index.html' : parsedUrl.pathname;
154 |
155 | // Handle POST requests
156 | if (req.method === 'POST') {
157 | handlePostRequest(req, res, parsedUrl);
158 | return;
159 | }
160 |
161 | // In production mode, serve static files from dist directory
162 | if (isProduction) {
163 | // Strip leading slashes so path.join/resolve can't ignore DIST_DIR
164 | let filePath = path.join(DIST_DIR, pathName.replace(/^\/+/, ''));
165 |
166 | // Security check - prevent directory traversal
167 | const resolvedDistDir = path.resolve(DIST_DIR);
168 | const resolvedFilePath = path.resolve(filePath);
169 | const relativePath = path.relative(resolvedDistDir, resolvedFilePath);
170 |
171 | // Reject if path tries to traverse outside the base directory
172 | if (relativePath.startsWith('..')) {
173 | res.writeHead(403, { 'Content-Type': 'text/plain' });
174 | res.end('Forbidden');
175 | return;
176 | }
177 |
178 | serveFile(filePath, res);
179 | } else {
180 | // Development mode - static files are served by Vite
181 | res.writeHead(404, { 'Content-Type': 'text/plain' });
182 | res.end('Not found (development mode - use Vite dev server `npm run start:dev`)');
183 | }
184 | });
185 |
186 | // Create WebSocket server only if WebSocket is available
187 | // Note: WebSocket upgrade handling is performed automatically by the ws library
188 | // when attached to the HTTP server. The HTTP request handler should NOT send
189 | // a response for upgrade requests - the ws library handles the upgrade internally.
190 | if (isWebSocketAvailable) {
191 | const wss = new WebSocket.Server({
192 | server,
193 | path: '/ws'
194 | });
195 |
196 | wss.on('connection', (ws, req) => {
197 | console.log('New WebSocket client connected');
198 | wsClients.add(ws);
199 |
200 | ws.on('close', () => {
201 | console.log('WebSocket client disconnected');
202 | wsClients.delete(ws);
203 | });
204 |
205 | ws.on('error', (error) => {
206 | console.error('WebSocket error:', error);
207 | wsClients.delete(ws);
208 | });
209 | });
210 | }
211 |
212 | // Start server
213 | server.listen(PORT, () => {
214 | console.log(`Server running at http://localhost:${PORT}`);
215 | if (isProduction) {
216 | console.log(`Serving static files from: ${DIST_DIR}`);
217 | } else {
218 | console.log(`Development mode - static files served by Vite`);
219 | }
220 | if (isWebSocketAvailable) {
221 | console.log(`WebSocket server running on /ws`);
222 | } else {
223 | console.log(`WebSocket functionality disabled - install 'ws' package to enable`);
224 | }
225 | console.log('Press Ctrl+C to stop the server');
226 | });
227 |
228 | // Handle server errors
229 | server.on('error', (err) => {
230 | if (err.code === 'EADDRINUSE') {
231 | console.error(`Port ${PORT} is already in use. Please try a different port.`);
232 | } else {
233 | console.error('Server error:', err);
234 | }
235 | process.exit(1);
236 | });
237 |
238 | // Graceful shutdown
239 | process.on('SIGINT', () => {
240 | console.log('\nShutting down server...');
241 | server.close(() => {
242 | console.log('Server closed');
243 | process.exit(0);
244 | });
245 | });
246 |
--------------------------------------------------------------------------------
/BESPOKE-TEMPLATE.md:
--------------------------------------------------------------------------------
1 | # Bespoke Simulation Template
2 |
3 | This document provides precise implementation instructions for creating
4 | embedded applications using the Bespoke Simulation template. Follow these
5 | instructions exactly to ensure consistency across all applications.
6 | NOTE: Never edit this `BESPOKE-TEMPLATE.md` file. Codebase changes should be reflected in the `AGENTS.md` file.
7 |
8 | ## Required Files Structure
9 |
10 | Every application should include these files in the following order:
11 |
12 | 1. CodeSignal Design System foundations:
13 | - colors/colors.css
14 | - spacing/spacing.css
15 | - typography/typography.css
16 | - components/button/button.css (used in header)
17 | 2. CodeSignal Design System components (optional):
18 | - components/boxes/boxes.css
19 | - components/dropdown/dropdown.css
20 | - components/input/input.css
21 | - components/tags/tags.css
22 | 3. bespoke-template.css (template-specific layout, utilities, temporary
23 | components)
24 | 4. help-modal.js (help system)
25 | 5. app.js (application logic)
26 | 6. server.js (server)
27 |
28 | ## HTML Template Implementation
29 |
30 | 1. REPLACE the following placeholders in index.html EXACTLY as specified:
31 |
32 | a) ``
33 | Replace with your application's page title
34 | Example: "Database Designer" or "Task Manager"
35 |
36 | b) ``
37 | Replace with your application's display name (appears in header)
38 | Example: "Database Designer" or "Task Manager"
39 |
40 | c) ``
41 | Add your application's main content area
42 | Example: `
` or `
`
43 |
44 | d) ``
45 | Add links to your application-specific CSS files
46 | Example: ` `
47 |
48 | e) ``
49 | Add links to your application-specific JavaScript files
50 | Example: ``
51 |
52 | 2. DO NOT modify the core structure (header, script loading order, etc.)
53 |
54 | ## CSS Implementation
55 |
56 | 1. ALWAYS use the `.bespoke` class on the body element for scoping
57 | 2. USE design system components directly with proper classes:
58 | - Buttons: `button button-primary`, `button button-secondary`,
59 | `button button-danger`, `button button-text`
60 | - Boxes/Cards: `box card` for card containers
61 | - Inputs: Add `input` class to input elements:
62 | ` `
63 | 3. USE design system CSS custom properties for styling:
64 | - Colors: `--Colors-*` (e.g., `--Colors-Primary-Default`,
65 | `--Colors-Text-Body-Default`)
66 | - Spacing: `--UI-Spacing-*` (e.g., `--UI-Spacing-spacing-ml`,
67 | `--UI-Spacing-spacing-xl`)
68 | - Typography: `--Fonts-*` (e.g., `--Fonts-Body-Default-md`,
69 | `--Fonts-Headlines-sm`)
70 | - Borders: `--UI-Radius-*` (e.g., `--UI-Radius-radius-s`,
71 | `--UI-Radius-radius-m`)
72 | - Font families: `--body-family`, `--heading-family`
73 | 4. FOR custom styling, create app-specific CSS files
74 | 5. OVERRIDE design system variables in your app-specific CSS, not in
75 | bespoke-template.css
76 | 6. FOLLOW design system naming conventions for consistency
77 |
78 | ## JavaScript Implementation
79 |
80 | 1. HELP MODAL SETUP:
81 | a) Create help content using help-content-template.html as reference
82 | b) Initialize HelpModal with:
83 | - triggerSelector: `'#btn-help'`
84 | - content: your help content (string or loaded from file)
85 | - theme: `'auto'`
86 |
87 | 2. STATUS MANAGEMENT:
88 | a) Use the provided setStatus() function for status updates
89 | b) Update status for: loading, saving, errors, user actions
90 | c) Keep status messages concise and informative
91 |
92 | ## Error Handling Requirements
93 |
94 | 1. WRAP all async operations in try-catch blocks
95 | 2. PROVIDE meaningful error messages to users
96 | 3. LOG errors to console for debugging
97 | 4. IMPLEMENT retry logic for network operations
98 | 5. HANDLE localStorage quota exceeded errors
99 | 6. VALIDATE data before saving operations
100 |
101 | ## Status Message Conventions
102 |
103 | Use these EXACT status messages for consistency:
104 |
105 | - "Ready" - Application loaded successfully
106 | - "Loading..." - Data is being loaded
107 | - "Saving..." - Data is being saved
108 | - "Changes saved" - Auto-save completed successfully
109 | - "Save failed (will retry)" - Server save failed, will retry
110 | - "Failed to load data" - Data loading failed
111 | - "Auto-save initialized" - Auto-save system started
112 |
113 | ## File Naming Conventions
114 |
115 | 1. CSS files: kebab-case (e.g., my-app.css, task-manager.css)
116 | 2. JavaScript files: kebab-case (e.g., my-app.js, task-manager.js)
117 | 3. Data files: kebab-case (e.g., solution.json, initial-data.json)
118 | 4. Image files: kebab-case (e.g., overview.png, help-icon.svg)
119 |
120 | ---
121 |
122 | # Bespoke Template Design System Guidelines
123 |
124 | This section explains how to use the CodeSignal Design System with the
125 | Bespoke template for embedded applications.
126 |
127 | ## Overview
128 |
129 | The Bespoke template uses the CodeSignal Design System for components and
130 | tokens, with template-specific layout and utilities. All styles are scoped
131 | under the `.bespoke` class to prevent interference with parent site styles.
132 | The template uses design system components directly where available, and
133 | provides temporary components (modals, form elements) that will be replaced
134 | when the design system adds them.
135 |
136 | ## Basic Usage
137 |
138 | ### 1. Include the CSS
139 |
140 | ```html
141 |
142 | ```
143 |
144 | ### 2. Wrap Your Application
145 |
146 | ```html
147 |
148 |
149 |
150 | ```
151 |
152 | ### 3. Use the Component Classes
153 |
154 | ```html
155 |
156 |
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 | .bespoke .typing-container {
10 | position: relative;
11 | width: 100%;
12 | height: 100%;
13 | display: flex;
14 | flex-direction: column;
15 | align-items: center;
16 | justify-content: flex-start;
17 | padding: var(--UI-Spacing-spacing-xl);
18 | overflow: hidden; /* Prevent scrolling */
19 | min-height: 0; /* Allow flexbox to shrink */
20 | }
21 |
22 | /* Keyboard and Stats Wrapper */
23 | .bespoke .keyboard-stats-wrapper {
24 | position: relative;
25 | width: 100%;
26 | max-width: 900px;
27 | margin: var(--UI-Spacing-spacing-md) auto 0;
28 | flex: 0 0 auto; /* Don't grow, don't shrink */
29 | min-height: 0; /* Allow flexbox to shrink */
30 | }
31 |
32 | /* Real-time Stats Container */
33 | .bespoke .realtime-stats-container {
34 | display: none;
35 | position: absolute;
36 | left: 0;
37 | bottom: 0;
38 | flex-direction: column;
39 | gap: 2px;
40 | padding: var(--UI-Spacing-spacing-md);
41 | background: var(--Colors-Box-Background-Secondary);
42 | border: none;
43 | border-radius: var(--UI-Radius-radius-m);
44 | width: 140px;
45 | margin-right: var(--UI-Spacing-spacing-xl);
46 | z-index: 1;
47 | }
48 |
49 | .bespoke .realtime-stat-item {
50 | display: flex;
51 | flex-direction: row;
52 | align-items: center;
53 | justify-content: space-between;
54 | gap: var(--UI-Spacing-spacing-xs);
55 | padding: 0;
56 | min-width: 0;
57 | }
58 |
59 | .bespoke .realtime-stat-label {
60 | font-size: var(--Fonts-Body-Default-xs);
61 | color: var(--Colors-Text-Body-Medium);
62 | text-transform: uppercase;
63 | letter-spacing: 0.5px;
64 | font-weight: 500;
65 | white-space: nowrap;
66 | flex-shrink: 0;
67 | }
68 |
69 | .bespoke .realtime-stat-value {
70 | font-size: var(--Fonts-Headlines-sm);
71 | font-weight: 600;
72 | color: var(--Colors-Primary-Default);
73 | font-family: var(--heading-family);
74 | text-align: right;
75 | flex-shrink: 0;
76 | min-width: 60px;
77 | font-variant-numeric: tabular-nums;
78 | }
79 |
80 | .bespoke .typing-text-container {
81 | flex: 1 1 auto;
82 | position: relative;
83 | overflow: hidden; /* Remove scrolling - content must fit */
84 | padding: var(--UI-Spacing-spacing-mxl);
85 | background: transparent;
86 | font-family: var(--body-family);
87 | font-size: var(--Fonts-Body-Default-xxxl);
88 | line-height: 140%;
89 | cursor: text;
90 | max-width: 900px;
91 | width: 100%;
92 | margin: var(--UI-Spacing-spacing-lg) auto 0;
93 | min-height: 0; /* Allow flexbox to shrink */
94 | display: flex;
95 | flex-direction: column;
96 | }
97 |
98 | .bespoke .typing-text {
99 | white-space: pre-wrap;
100 | word-wrap: break-word;
101 | user-select: none;
102 | text-align: left;
103 | width: 100%;
104 | overflow: hidden; /* Prevent text from causing scroll */
105 | flex: 1 1 auto;
106 | min-height: 0;
107 | }
108 |
109 | .bespoke .typing-text .char-pending {
110 | color: var(--Colors-Text-Body-Light);
111 | }
112 |
113 | .bespoke .typing-text .char-correct {
114 | color: var(--Colors-Text-Body-Strongest);
115 | }
116 |
117 | .bespoke .typing-text .char-incorrect {
118 | color: var(--Colors-Alert-Error-Default);
119 | }
120 |
121 | .bespoke .typing-text .cursor-position {
122 | position: relative;
123 | }
124 |
125 | .bespoke .typing-text .cursor-position::before {
126 | content: '';
127 | position: absolute;
128 | left: -1px;
129 | top: 0;
130 | bottom: 0;
131 | width: 2px;
132 | background-color: var(--Colors-Primary-Default);
133 | animation: blink 1s step-end infinite;
134 | pointer-events: none;
135 | }
136 |
137 | @keyframes blink {
138 | 0%, 50% {
139 | opacity: 1;
140 | }
141 | 51%, 100% {
142 | opacity: 0;
143 | }
144 | }
145 |
146 | .bespoke .hidden-input {
147 | position: absolute;
148 | opacity: 0;
149 | pointer-events: none;
150 | width: 0;
151 | height: 0;
152 | border: none;
153 | padding: 0;
154 | margin: 0;
155 | }
156 |
157 | .bespoke .restart-button-container {
158 | position: fixed;
159 | bottom: var(--UI-Spacing-spacing-mxl);
160 | left: var(--UI-Spacing-spacing-mxl);
161 | z-index: 1000;
162 | }
163 |
164 | .bespoke .restart-button-container button {
165 | display: flex;
166 | align-items: center;
167 | justify-content: center;
168 | width: 40px;
169 | height: 40px;
170 | padding: 0;
171 | border-radius: var(--UI-Radius-radius-s);
172 | }
173 |
174 | .bespoke .restart-button-container button:active,
175 | .bespoke .restart-button-container button.active {
176 | box-shadow: none;
177 | border-bottom: none;
178 | color: var(--Colors-Buttons-Text-Primary-Hover);
179 | }
180 |
181 | .bespoke .restart-button-container button:focus,
182 | .bespoke .restart-button-container button.focus {
183 | box-shadow: none;
184 | border-bottom: none;
185 | outline: none;
186 | color: var(--Colors-Buttons-Text-Primary-Hover);
187 | }
188 |
189 | .bespoke .restart-button-container button svg {
190 | width: 20px;
191 | height: 20px;
192 | }
193 |
194 | .bespoke .completion-screen {
195 | display: none;
196 | position: fixed;
197 | top: 50%;
198 | left: 50%;
199 | transform: translate(-50%, -50%);
200 | flex-direction: column;
201 | align-items: center;
202 | justify-content: center;
203 | gap: var(--UI-Spacing-spacing-xl);
204 | padding: var(--UI-Spacing-spacing-xxxl);
205 | background: var(--Colors-Box-Background);
206 | border-radius: var(--UI-Radius-radius-m);
207 | border: 1px solid var(--Colors-Stroke-Default);
208 | text-align: center;
209 | z-index: 1000;
210 | min-width: 400px;
211 | max-width: 90vw;
212 | max-height: 90vh;
213 | overflow-y: auto;
214 | box-shadow: 0 3px 2px 0 var(--Colors-Shadow-Card);
215 | }
216 |
217 | .bespoke .completion-screen h2 {
218 | font-size: var(--Fonts-Headlines-md);
219 | margin: 0;
220 | color: var(--Colors-Text-Body-Strongest);
221 | font-family: var(--heading-family);
222 | font-weight: 500;
223 | }
224 |
225 | .bespoke .completion-screen p {
226 | font-size: var(--Fonts-Body-Default-lg);
227 | color: var(--Colors-Text-Body-Medium);
228 | margin: 0;
229 | }
230 |
231 | /* Stats Dashboard Styles */
232 | .bespoke .stats-dashboard {
233 | display: none;
234 | position: fixed;
235 | top: 50%;
236 | left: 50%;
237 | transform: translate(-50%, -50%);
238 | flex-direction: column;
239 | align-items: center;
240 | justify-content: flex-start;
241 | gap: var(--UI-Spacing-spacing-xl);
242 | padding: var(--UI-Spacing-spacing-xl);
243 | background: var(--Colors-Backgrounds-Main-Default);
244 | text-align: center;
245 | z-index: 1000;
246 | min-width: 600px;
247 | max-width: 90vw;
248 | max-height: 90vh;
249 | overflow-y: auto;
250 | }
251 |
252 | .bespoke .stats-dashboard-header {
253 | width: 100%;
254 | margin-bottom: var(--UI-Spacing-spacing-lg);
255 | }
256 |
257 | .bespoke .stats-dashboard-header h2 {
258 | font-size: var(--Fonts-Headlines-xl);
259 | margin: 0 0 var(--UI-Spacing-spacing-xs) 0;
260 | color: var(--Colors-Text-Body-Strongest);
261 | font-family: var(--heading-family);
262 | font-weight: 500;
263 | }
264 |
265 | .bespoke .stats-subtitle {
266 | font-size: var(--Fonts-Body-Default-md);
267 | color: var(--Colors-Text-Body-Medium);
268 | margin: 0;
269 | }
270 |
271 | .bespoke .stats-grid {
272 | display: grid;
273 | grid-template-columns: repeat(5, 1fr);
274 | gap: var(--UI-Spacing-spacing-mxl);
275 | width: 100%;
276 | margin: var(--UI-Spacing-spacing-xl) 0;
277 | }
278 |
279 | .bespoke .stat-card {
280 | display: flex;
281 | flex-direction: column;
282 | align-items: center;
283 | justify-content: center;
284 | padding: var(--UI-Spacing-spacing-mxl);
285 | background: var(--Colors-Box-Background-Secondary);
286 | border: 1px solid var(--Colors-Stroke-Default);
287 | border-radius: var(--UI-Radius-radius-m);
288 | transition: transform 0.2s ease, box-shadow 0.2s ease;
289 | }
290 |
291 | .bespoke .stat-card:hover {
292 | transform: translateY(-2px);
293 | box-shadow: 0 4px 8px 0 var(--Colors-Shadow-Card);
294 | }
295 |
296 | .bespoke .stat-value-container {
297 | display: flex;
298 | align-items: center;
299 | justify-content: center;
300 | margin-bottom: var(--UI-Spacing-spacing-xs);
301 | }
302 |
303 | .bespoke .stat-value {
304 | font-size: var(--Fonts-Headlines-sm);
305 | font-weight: 600;
306 | color: var(--Colors-Primary-Default);
307 | font-family: var(--heading-family);
308 | }
309 |
310 | .bespoke .stat-label {
311 | font-size: var(--Fonts-Body-Default-sm);
312 | color: var(--Colors-Text-Body-Medium);
313 | text-transform: uppercase;
314 | letter-spacing: 0.5px;
315 | }
316 |
317 | .bespoke .stats-dashboard-footer {
318 | width: 100%;
319 | margin-top: var(--UI-Spacing-spacing-lg);
320 | }
321 |
322 | /* Mini Keyboard Styles */
323 | .bespoke .keyboard-container {
324 | display: none;
325 | width: 100%;
326 | padding: var(--UI-Spacing-spacing-md) var(--UI-Spacing-spacing-lg) var(--UI-Spacing-spacing-sm) var(--UI-Spacing-spacing-lg);
327 | background: transparent;
328 | overflow: visible; /* Allow key tip glow to be visible */
329 | }
330 |
331 | .bespoke .keyboard-container.visible {
332 | display: block;
333 | }
334 |
335 | .bespoke .keyboard-container .keyboard {
336 | margin: 0 auto; /* Center the keyboard */
337 | }
338 |
339 | .bespoke .keyboard-container.visible {
340 | display: block;
341 | }
342 |
343 | .bespoke .keyboard {
344 | display: flex;
345 | flex-direction: column;
346 | gap: var(--UI-Spacing-spacing-xs);
347 | font-family: var(--body-family);
348 | font-size: 12px;
349 | user-select: none;
350 | }
351 |
352 | .bespoke .keyboard-row {
353 | display: flex;
354 | justify-content: center;
355 | gap: var(--UI-Spacing-spacing-xs);
356 | }
357 |
358 | .bespoke .keyboard-key {
359 | display: flex;
360 | align-items: center;
361 | justify-content: center;
362 | min-width: 28px;
363 | height: 32px;
364 | padding: 0 var(--UI-Spacing-spacing-sm);
365 | background: var(--Colors-Box-Background-Secondary);
366 | border: 1px solid var(--Colors-Stroke-Default);
367 | border-radius: var(--UI-Radius-radius-s);
368 | color: var(--Colors-Text-Body-Strongest);
369 | font-weight: 500;
370 | transition: all 0.15s ease;
371 | box-shadow: 0 1px 2px 0 var(--Colors-Shadow-Card);
372 | }
373 |
374 | .bespoke .keyboard-key.space {
375 | min-width: 200px;
376 | }
377 |
378 | .bespoke .keyboard-key.enter {
379 | min-width: 60px;
380 | }
381 |
382 | .bespoke .keyboard-key.shift {
383 | min-width: 70px;
384 | }
385 |
386 | .bespoke .keyboard-key.backspace {
387 | min-width: 70px;
388 | }
389 |
390 | .bespoke .keyboard-key.tab {
391 | min-width: 50px;
392 | }
393 |
394 | .bespoke .keyboard-key.caps {
395 | min-width: 60px;
396 | }
397 |
398 | .bespoke .keyboard-key.active {
399 | background: color-mix(in srgb, var(--Colors-Primary-Default) 30%, transparent);
400 | border-color: var(--Colors-Primary-Default);
401 | transform: scale(0.95);
402 | }
403 |
404 | .bespoke .keyboard-key.active-error {
405 | background: color-mix(in srgb, var(--Colors-Alert-Error-Default) 30%, transparent);
406 | border-color: var(--Colors-Alert-Error-Default);
407 | transform: scale(0.95);
408 | }
409 |
410 | .bespoke .keyboard-key.unavailable {
411 | background: var(--Colors-Box-Background);
412 | border-color: var(--Colors-Stroke-Light);
413 | color: var(--Colors-Text-Body-Light);
414 | opacity: 0.5;
415 | cursor: not-allowed;
416 | }
417 |
418 | .bespoke .keyboard-key.key-tip {
419 | background: color-mix(in srgb, var(--Colors-Primary-Default) 40%, transparent);
420 | border-color: var(--Colors-Primary-Default);
421 | animation: keyTipPulse 1.5s ease-in-out infinite;
422 | box-shadow: 0 0 8px 2px color-mix(in srgb, var(--Colors-Primary-Default) 50%, transparent);
423 | }
424 |
425 | @keyframes keyTipPulse {
426 | 0%, 100% {
427 | transform: scale(1);
428 | box-shadow: 0 0 8px 2px color-mix(in srgb, var(--Colors-Primary-Default) 50%, transparent);
429 | }
430 | 50% {
431 | transform: scale(1.05);
432 | box-shadow: 0 0 12px 4px color-mix(in srgb, var(--Colors-Primary-Default) 70%, transparent);
433 | }
434 | }
435 |
436 | /* Responsive Design */
437 | @media (max-width: 1024px) {
438 | .bespoke .keyboard-stats-wrapper {
439 | margin: var(--UI-Spacing-spacing-lg) auto 0;
440 | }
441 |
442 | .bespoke .realtime-stats-container {
443 | gap: 2px;
444 | padding: var(--UI-Spacing-spacing-sm);
445 | min-width: 130px;
446 | }
447 |
448 | .bespoke .realtime-stat-item {
449 | padding: 0;
450 | gap: var(--UI-Spacing-spacing-sm);
451 | }
452 |
453 | .bespoke .realtime-stat-label {
454 | font-size: var(--Fonts-Body-Default-xxs);
455 | }
456 |
457 | .bespoke .realtime-stat-value {
458 | font-size: var(--Fonts-Headlines-xs);
459 | }
460 |
461 | .bespoke .typing-text-container {
462 | font-size: var(--Fonts-Body-Default-xxl);
463 | padding: var(--UI-Spacing-spacing-xl);
464 | margin: var(--UI-Spacing-spacing-md) auto 0;
465 | }
466 |
467 | .bespoke .keyboard-container {
468 | padding: var(--UI-Spacing-spacing-md);
469 | }
470 |
471 | .bespoke .keyboard {
472 | font-size: 11px;
473 | }
474 |
475 | .bespoke .keyboard-key {
476 | min-width: 24px;
477 | height: 28px;
478 | padding: 0 var(--UI-Spacing-spacing-xs);
479 | }
480 |
481 | .bespoke .keyboard-key.space {
482 | min-width: 150px;
483 | }
484 |
485 | .bespoke .keyboard-key.enter {
486 | min-width: 50px;
487 | }
488 |
489 | .bespoke .keyboard-key.shift {
490 | min-width: 60px;
491 | }
492 |
493 | .bespoke .keyboard-key.backspace {
494 | min-width: 60px;
495 | }
496 |
497 | .bespoke .keyboard-key.tab {
498 | min-width: 40px;
499 | }
500 |
501 | .bespoke .keyboard-key.caps {
502 | min-width: 50px;
503 | }
504 | }
505 |
506 | @media (max-width: 768px) {
507 | .bespoke .keyboard-stats-wrapper {
508 | margin: var(--UI-Spacing-spacing-md) auto 0;
509 | }
510 |
511 | .bespoke .realtime-stats-container {
512 | position: static;
513 | width: 100%;
514 | max-width: 100%;
515 | min-width: auto;
516 | gap: 2px;
517 | padding: var(--UI-Spacing-spacing-sm);
518 | margin-bottom: var(--UI-Spacing-spacing-md);
519 | }
520 |
521 | .bespoke .realtime-stat-item {
522 | padding: 0;
523 | gap: var(--UI-Spacing-spacing-sm);
524 | }
525 |
526 | .bespoke .keyboard-container {
527 | width: 100%;
528 | }
529 |
530 | .bespoke .typing-container {
531 | padding: var(--UI-Spacing-spacing-md);
532 | }
533 |
534 | .bespoke .typing-text-container {
535 | font-size: var(--Fonts-Body-Default-xl);
536 | padding: var(--UI-Spacing-spacing-lg);
537 | margin: var(--UI-Spacing-spacing-sm) auto 0;
538 | }
539 |
540 | .bespoke .completion-screen {
541 | min-width: 90vw;
542 | max-width: 90vw;
543 | padding: var(--UI-Spacing-spacing-xl);
544 | gap: var(--UI-Spacing-spacing-lg);
545 | }
546 |
547 | .bespoke .completion-screen h2 {
548 | font-size: var(--Fonts-Headlines-sm);
549 | }
550 |
551 | .bespoke .completion-screen p {
552 | font-size: var(--Fonts-Body-Default-md);
553 | }
554 |
555 | .bespoke .stats-dashboard {
556 | min-width: 90vw;
557 | max-width: 90vw;
558 | padding: var(--UI-Spacing-spacing-xl);
559 | gap: var(--UI-Spacing-spacing-lg);
560 | }
561 |
562 | .bespoke .stats-dashboard-header h2 {
563 | font-size: var(--Fonts-Headlines-lg);
564 | }
565 |
566 | .bespoke .stats-subtitle {
567 | font-size: var(--Fonts-Body-Default-sm);
568 | }
569 |
570 | .bespoke .stats-grid {
571 | grid-template-columns: repeat(5, 1fr);
572 | gap: var(--UI-Spacing-spacing-m);
573 | }
574 |
575 | .bespoke .stat-card {
576 | padding: var(--UI-Spacing-spacing-m);
577 | }
578 |
579 | .bespoke .stat-value {
580 | font-size: var(--Fonts-Headlines-xs);
581 | }
582 |
583 | .bespoke .stat-label {
584 | font-size: var(--Fonts-Body-Default-xs);
585 | }
586 |
587 | .bespoke .keyboard-container {
588 | padding: var(--UI-Spacing-spacing-sm);
589 | margin: var(--UI-Spacing-spacing-md) auto 0;
590 | }
591 |
592 | .bespoke .keyboard {
593 | font-size: 10px;
594 | gap: 2px;
595 | }
596 |
597 | .bespoke .keyboard-row {
598 | gap: 2px;
599 | }
600 |
601 | .bespoke .keyboard-key {
602 | min-width: 20px;
603 | height: 24px;
604 | padding: 0 4px;
605 | font-size: 10px;
606 | }
607 |
608 | .bespoke .keyboard-key.space {
609 | min-width: 120px;
610 | }
611 |
612 | .bespoke .keyboard-key.enter {
613 | min-width: 40px;
614 | }
615 |
616 | .bespoke .keyboard-key.shift {
617 | min-width: 50px;
618 | }
619 |
620 | .bespoke .keyboard-key.backspace {
621 | min-width: 50px;
622 | }
623 |
624 | .bespoke .keyboard-key.tab {
625 | min-width: 35px;
626 | }
627 |
628 | .bespoke .keyboard-key.caps {
629 | min-width: 40px;
630 | }
631 |
632 | .bespoke .restart-button-container {
633 | bottom: var(--UI-Spacing-spacing-md);
634 | left: var(--UI-Spacing-spacing-md);
635 | }
636 |
637 | .bespoke .restart-button-container button {
638 | width: 36px;
639 | height: 36px;
640 | }
641 |
642 | .bespoke .restart-button-container button svg {
643 | width: 18px;
644 | height: 18px;
645 | }
646 | }
647 |
648 | @media (max-width: 480px) {
649 | .bespoke .keyboard-stats-wrapper {
650 | margin: var(--UI-Spacing-spacing-sm) auto 0;
651 | }
652 |
653 | .bespoke .realtime-stats-container {
654 | position: static;
655 | width: 100%;
656 | max-width: 100%;
657 | min-width: auto;
658 | gap: 2px;
659 | padding: var(--UI-Spacing-spacing-xs);
660 | margin-bottom: var(--UI-Spacing-spacing-sm);
661 | }
662 |
663 | .bespoke .realtime-stat-item {
664 | padding: 0;
665 | gap: var(--UI-Spacing-spacing-xs);
666 | }
667 |
668 | .bespoke .typing-container {
669 | padding: var(--UI-Spacing-spacing-sm);
670 | }
671 |
672 | .bespoke .typing-text-container {
673 | font-size: var(--Fonts-Body-Default-lg);
674 | padding: var(--UI-Spacing-spacing-md);
675 | margin: var(--UI-Spacing-spacing-xs) auto 0;
676 | }
677 |
678 | .bespoke .completion-screen {
679 | padding: var(--UI-Spacing-spacing-lg);
680 | gap: var(--UI-Spacing-spacing-md);
681 | }
682 |
683 | .bespoke .stats-dashboard {
684 | padding: var(--UI-Spacing-spacing-lg);
685 | gap: var(--UI-Spacing-spacing-md);
686 | }
687 |
688 | .bespoke .stats-grid {
689 | grid-template-columns: repeat(5, 1fr);
690 | gap: var(--UI-Spacing-spacing-s);
691 | }
692 |
693 | .bespoke .stat-card {
694 | padding: var(--UI-Spacing-spacing-s);
695 | }
696 |
697 | .bespoke .stat-value {
698 | font-size: 0.875rem;
699 | }
700 |
701 | .bespoke .keyboard-container {
702 | padding: var(--UI-Spacing-spacing-xs);
703 | margin: var(--UI-Spacing-spacing-sm) auto 0;
704 | }
705 |
706 | .bespoke .keyboard {
707 | font-size: 8px;
708 | gap: 1px;
709 | }
710 |
711 | .bespoke .keyboard-row {
712 | gap: 1px;
713 | }
714 |
715 | .bespoke .keyboard-key {
716 | min-width: 16px;
717 | height: 20px;
718 | padding: 0 2px;
719 | font-size: 8px;
720 | }
721 |
722 | .bespoke .keyboard-key.space {
723 | min-width: 100px;
724 | }
725 |
726 | .bespoke .keyboard-key.enter {
727 | min-width: 35px;
728 | }
729 |
730 | .bespoke .keyboard-key.shift {
731 | min-width: 40px;
732 | }
733 |
734 | .bespoke .keyboard-key.backspace {
735 | min-width: 40px;
736 | }
737 |
738 | .bespoke .keyboard-key.tab {
739 | min-width: 30px;
740 | }
741 |
742 | .bespoke .keyboard-key.caps {
743 | min-width: 35px;
744 | }
745 | }
746 |
--------------------------------------------------------------------------------
/client/typing-simulator.js:
--------------------------------------------------------------------------------
1 | // typing-simulator.js
2 | (function() {
3 | let originalText = '';
4 | let typedText = '';
5 | let textContainer = null;
6 | let hiddenInput = null;
7 | let completionScreen = null;
8 | let statsDashboard = null;
9 | let restartButton = null;
10 | let startOverButton = null;
11 | let statsStartOverButton = null;
12 | let keyboardContainer = null;
13 | let realtimeStatsContainer = null;
14 | let config = { keyboard: true, availableKeys: [], showStats: false, realTimeStats: [], keyTips: false };
15 |
16 | // Normalized set of available keys (for fast lookup)
17 | let availableKeysSet = new Set();
18 |
19 | // Character states: 'pending', 'correct', 'incorrect'
20 | const charStates = [];
21 |
22 | // Statistics tracking
23 | let startTime = null;
24 | let totalErrors = 0;
25 | let totalInputs = 0;
26 |
27 | // Keyboard state
28 | let keyboardEnabled = false;
29 | let activeKeyElement = null;
30 | let activeKeyTimeout = null;
31 | let keyTipsEnabled = false;
32 | let currentTipKeyElement = null;
33 | let currentTipShiftElement = null; // Track which shift key is highlighted
34 |
35 | // Real-time stats update interval
36 | let realtimeStatsInterval = null;
37 |
38 | function setStatus(msg) {
39 | const status = document.getElementById('status');
40 | if (status) {
41 | status.textContent = msg;
42 | }
43 | }
44 |
45 | // Load configuration
46 | async function loadConfig() {
47 | try {
48 | const response = await fetch('./config.json');
49 | if (!response.ok) {
50 | console.warn('Config file not found, using defaults');
51 | return;
52 | }
53 | config = await response.json();
54 |
55 | // Normalize available keys to lowercase for fast lookup
56 | // Empty array means all keys are available
57 | if (config.availableKeys && Array.isArray(config.availableKeys) && config.availableKeys.length > 0) {
58 | availableKeysSet = new Set(config.availableKeys.map(key => key.toLowerCase()));
59 | } else {
60 | availableKeysSet = new Set(); // Empty set means all keys available
61 | }
62 | } catch (error) {
63 | console.warn('Error loading config:', error);
64 | }
65 | }
66 |
67 | // Keyboard layout definition
68 | const keyboardLayout = [
69 | ['`', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '=', 'backspace'],
70 | ['tab', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '[', ']', '\\'],
71 | ['caps', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', "'", 'enter'],
72 | ['shift', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '/', 'shift'],
73 | ['space']
74 | ];
75 |
76 | // Map special keys to display names
77 | const keyDisplayNames = {
78 | 'backspace': '⌫',
79 | 'tab': 'Tab',
80 | 'caps': 'Caps',
81 | 'enter': 'Enter',
82 | 'shift': 'Shift',
83 | 'space': 'Space'
84 | };
85 |
86 | // Check if a key is available for typing
87 | // Accepts both character values (from input) and KeyboardEvent.key values
88 | function isKeyAvailable(key) {
89 | // Space, comma, dot, backspace, and enter are ALWAYS available
90 | const keyLower = key.toLowerCase();
91 | if (key === ' ' || key === '\u00A0' || key === ',' || key === '.' ||
92 | key === 'Backspace' || key === '\b' || key === 'Enter' || key === 'Return' ||
93 | key === '\n' || key === '\r' ||
94 | keyLower === 'space' || keyLower === 'comma' || keyLower === 'dot' ||
95 | keyLower === 'backspace' || keyLower === 'enter') {
96 | return true;
97 | }
98 |
99 | // If no available keys are configured, all keys are available
100 | if (availableKeysSet.size === 0) {
101 | return true;
102 | }
103 |
104 | // Handle KeyboardEvent.key values (e.g., "Tab")
105 | if (key === 'Tab' || keyLower === 'tab') {
106 | return availableKeysSet.has('tab');
107 | }
108 |
109 | // Handle character values (from input events)
110 | if (key === '\t') {
111 | return availableKeysSet.has('tab');
112 | }
113 |
114 | // For regular keys, normalize to lowercase and check
115 | // Handle both single characters and KeyboardEvent.key values
116 | const normalizedKey = key.length === 1 ? key.toLowerCase() : keyLower;
117 | return availableKeysSet.has(normalizedKey);
118 | }
119 |
120 | // Get key element by character
121 | function getKeyElement(char) {
122 | if (!keyboardContainer) return null;
123 |
124 | // Normalize character
125 | const normalizedChar = char.toLowerCase();
126 |
127 | // Handle special keys
128 | if (char === ' ') {
129 | return keyboardContainer.querySelector('[data-key="space"]');
130 | }
131 | if (char === '\n' || char === '\r') {
132 | return keyboardContainer.querySelector('[data-key="enter"]');
133 | }
134 | if (char === '\t') {
135 | return keyboardContainer.querySelector('[data-key="tab"]');
136 | }
137 |
138 | // Find regular key
139 | return keyboardContainer.querySelector(`[data-key="${normalizedChar}"]`);
140 | }
141 |
142 | // Map shift symbols to their base keys
143 | const shiftSymbolMap = {
144 | '!': '1', '@': '2', '#': '3', '$': '4', '%': '5',
145 | '^': '6', '&': '7', '*': '8', '(': '9', ')': '0',
146 | '_': '-', '+': '=', '{': '[', '}': ']', '|': '\\',
147 | ':': ';', '"': "'", '<': ',', '>': '.', '?': '/', '~': '`'
148 | };
149 |
150 | // Get the base key for a character (maps shift symbols to their base keys)
151 | function getBaseKey(char) {
152 | // If it's a shift symbol, return the base key
153 | if (shiftSymbolMap[char]) {
154 | return shiftSymbolMap[char];
155 | }
156 | // For uppercase letters, return lowercase
157 | if (char >= 'A' && char <= 'Z') {
158 | return char.toLowerCase();
159 | }
160 | // Otherwise return as-is
161 | return char;
162 | }
163 |
164 | // Check if a character requires Shift to type
165 | function requiresShift(char) {
166 | // Check if it's an uppercase letter
167 | if (char >= 'A' && char <= 'Z') {
168 | return true;
169 | }
170 |
171 | // Check if it's a special symbol that requires Shift
172 | if (shiftSymbolMap[char]) {
173 | return true;
174 | }
175 |
176 | return false;
177 | }
178 |
179 | // Determine if a key is on the left side of the keyboard
180 | // Returns true for left side, false for right side
181 | // Based on standard QWERTY layout:
182 | // Left: `, 1-5, tab, q-t, caps, a-g, shift, z-b
183 | // Right: 6-0, -, =, y-p, [, ], \, h-l, ;, ', n-/, shift
184 | function isKeyOnLeftSide(char) {
185 | const normalizedChar = char.toLowerCase();
186 |
187 | // Handle special keys
188 | if (normalizedChar === 'tab' || normalizedChar === 'caps' || normalizedChar === 'shift') {
189 | // These keys span or are on left side
190 | return true;
191 | }
192 |
193 | // Number row: left side is `, 1-5
194 | if (normalizedChar === '`' || (normalizedChar >= '1' && normalizedChar <= '5')) {
195 | return true;
196 | }
197 |
198 | // Top letter row: left side is q-t
199 | if (normalizedChar >= 'q' && normalizedChar <= 't') {
200 | return true;
201 | }
202 |
203 | // Middle letter row: left side is a-g
204 | if (normalizedChar >= 'a' && normalizedChar <= 'g') {
205 | return true;
206 | }
207 |
208 | // Bottom letter row: left side is z-b
209 | if (normalizedChar >= 'z' && normalizedChar <= 'b') {
210 | return true;
211 | }
212 |
213 | // Everything else is on the right side
214 | return false;
215 | }
216 |
217 | // Get shift key element (left or right)
218 | function getShiftKeyElement(isLeft) {
219 | if (!keyboardContainer) return null;
220 |
221 | // Find shift key by data attribute
222 | const shiftSide = isLeft ? 'left' : 'right';
223 | return keyboardContainer.querySelector(`[data-key="shift"][data-shift-side="${shiftSide}"]`);
224 | }
225 |
226 | // Update the key tip highlight (shows which key to press next)
227 | function updateKeyTip() {
228 | // Only update if keyTips mode is enabled and keyboard is visible
229 | if (!keyTipsEnabled || !keyboardEnabled || !keyboardContainer) {
230 | return;
231 | }
232 |
233 | // Clear previous tip
234 | if (currentTipKeyElement) {
235 | currentTipKeyElement.classList.remove('key-tip');
236 | currentTipKeyElement = null;
237 | }
238 |
239 | // Clear previous shift tip
240 | if (currentTipShiftElement) {
241 | currentTipShiftElement.classList.remove('key-tip');
242 | currentTipShiftElement = null;
243 | }
244 |
245 | // Find the next character to type
246 | const nextCharIndex = typedText.length;
247 | if (nextCharIndex >= originalText.length) {
248 | // All characters typed, no tip needed
249 | return;
250 | }
251 |
252 | const nextChar = originalText[nextCharIndex];
253 | if (!nextChar) {
254 | return;
255 | }
256 |
257 | // Get the base key for highlighting (maps shift symbols to their base keys)
258 | const baseKey = getBaseKey(nextChar);
259 |
260 | // Get the key element for the base key
261 | const keyElement = getKeyElement(baseKey);
262 | if (keyElement) {
263 | currentTipKeyElement = keyElement;
264 | keyElement.classList.add('key-tip');
265 | }
266 |
267 | // Check if shift is needed and highlight appropriate shift key
268 | if (requiresShift(nextChar)) {
269 | // Determine which side of keyboard the key is on
270 | const charForSideCheck = baseKey.toLowerCase();
271 | const isLeftSide = isKeyOnLeftSide(charForSideCheck);
272 |
273 | // Highlight opposite shift: left side keys use right shift, right side keys use left shift
274 | const shiftElement = getShiftKeyElement(!isLeftSide);
275 | if (shiftElement) {
276 | currentTipShiftElement = shiftElement;
277 | shiftElement.classList.add('key-tip');
278 | }
279 | }
280 | }
281 |
282 | // Highlight a key on the keyboard
283 | function highlightKey(char, isError = false) {
284 | // Skip regular highlighting if keyTips mode is enabled
285 | if (keyTipsEnabled) {
286 | return;
287 | }
288 |
289 | // Don't highlight unavailable keys
290 | if (!isKeyAvailable(char)) {
291 | return;
292 | }
293 |
294 | // Clear previous highlight
295 | if (activeKeyElement) {
296 | activeKeyElement.classList.remove('active', 'active-error');
297 | }
298 |
299 | // Clear timeout if exists
300 | if (activeKeyTimeout) {
301 | clearTimeout(activeKeyTimeout);
302 | }
303 |
304 | const keyElement = getKeyElement(char);
305 | if (keyElement) {
306 | activeKeyElement = keyElement;
307 | if (isError) {
308 | keyElement.classList.add('active-error');
309 | } else {
310 | keyElement.classList.add('active');
311 | }
312 |
313 | // Remove highlight after animation
314 | activeKeyTimeout = setTimeout(() => {
315 | if (keyElement) {
316 | keyElement.classList.remove('active', 'active-error');
317 | }
318 | activeKeyElement = null;
319 | }, 200);
320 | }
321 | }
322 |
323 | // Render the keyboard
324 | function renderKeyboard() {
325 | if (!keyboardContainer) return;
326 |
327 | const keyboard = document.createElement('div');
328 | keyboard.className = 'keyboard';
329 |
330 | let shiftKeyIndex = 0; // Track which shift key we're rendering (0 = left, 1 = right)
331 |
332 | keyboardLayout.forEach(row => {
333 | const rowElement = document.createElement('div');
334 | rowElement.className = 'keyboard-row';
335 |
336 | row.forEach(key => {
337 | const keyElement = document.createElement('div');
338 | const normalizedKey = key.toLowerCase();
339 | keyElement.className = 'keyboard-key';
340 | keyElement.setAttribute('data-key', normalizedKey);
341 |
342 | // Add data attribute to distinguish left vs right shift
343 | if (key === 'shift') {
344 | const shiftSide = shiftKeyIndex === 0 ? 'left' : 'right';
345 | keyElement.setAttribute('data-shift-side', shiftSide);
346 | shiftKeyIndex++;
347 | }
348 |
349 | // Check if this key is available (use isKeyAvailable to ensure space, comma, dot are always available)
350 | const isAvailable = isKeyAvailable(key);
351 | if (!isAvailable) {
352 | keyElement.classList.add('unavailable');
353 | }
354 |
355 | // Add special class for certain keys
356 | if (key === 'space' || key === 'enter' || key === 'shift' ||
357 | key === 'backspace' || key === 'tab' || key === 'caps') {
358 | keyElement.classList.add(key);
359 | }
360 |
361 | // Set display text
362 | if (keyDisplayNames[key]) {
363 | keyElement.textContent = keyDisplayNames[key];
364 | } else {
365 | keyElement.textContent = key.toUpperCase();
366 | }
367 |
368 | rowElement.appendChild(keyElement);
369 | });
370 |
371 | keyboard.appendChild(rowElement);
372 | });
373 |
374 | keyboardContainer.innerHTML = '';
375 | keyboardContainer.appendChild(keyboard);
376 | }
377 |
378 | // Initialize keyboard
379 | function initializeKeyboard() {
380 | keyboardContainer = document.getElementById('keyboard-container');
381 | if (!keyboardContainer) return;
382 |
383 | keyboardEnabled = config.keyboard === true;
384 | keyTipsEnabled = config.keyTips === true; // Defaults to false if undefined or not set
385 |
386 | if (keyboardEnabled) {
387 | renderKeyboard();
388 | keyboardContainer.classList.add('visible');
389 | // Update key tip after keyboard is rendered
390 | if (keyTipsEnabled) {
391 | updateKeyTip();
392 | }
393 | } else {
394 | keyboardContainer.classList.remove('visible');
395 | }
396 | }
397 |
398 | async function loadText() {
399 | try {
400 | setStatus('Loading...');
401 | const response = await fetch('./text-to-input.txt');
402 | if (!response.ok) {
403 | throw new Error('Failed to load text file');
404 | }
405 | originalText = await response.text();
406 | // Trim trailing whitespace/newlines but keep the original for display
407 | originalText = originalText.trimEnd();
408 |
409 | // Initialize character states
410 | charStates.length = 0;
411 | for (let i = 0; i < originalText.length; i++) {
412 | charStates.push('pending');
413 | }
414 |
415 | renderText();
416 | setStatus('Ready');
417 | } catch (error) {
418 | console.error('Error loading text:', error);
419 | setStatus('Failed to load data');
420 | if (textContainer) {
421 | textContainer.innerHTML = 'Error: Could not load text file.
';
422 | }
423 | }
424 | }
425 |
426 | function renderText() {
427 | if (!textContainer) return;
428 |
429 | // Check if completed - show completion screen when all characters are typed
430 | // (regardless of whether they're correct or not)
431 | const typedTrimmed = typedText.trimEnd();
432 | const originalTrimmed = originalText.trimEnd();
433 |
434 | // Check if all characters are typed (even if there are mistakes)
435 | if (typedTrimmed.length === originalTrimmed.length) {
436 | console.log('Completion detected! Showing completion screen.');
437 | console.log('Typed length:', typedTrimmed.length, 'Original length:', originalTrimmed.length);
438 | showCompletionScreen();
439 | return;
440 | }
441 |
442 | // Hide completion screen if visible and show typing container
443 | if (completionScreen) {
444 | completionScreen.style.display = 'none';
445 | }
446 | const typingTextContainer = document.querySelector('.typing-text-container');
447 | if (typingTextContainer) {
448 | typingTextContainer.style.display = 'block';
449 | }
450 |
451 | let html = '';
452 | const currentPosition = typedText.length;
453 |
454 | for (let i = 0; i < originalText.length; i++) {
455 | const char = originalText[i];
456 | const state = charStates[i];
457 | let className = 'char-';
458 |
459 | if (i < typedText.length) {
460 | // Character has been typed
461 | if (state === 'incorrect') {
462 | className += 'incorrect';
463 | } else {
464 | className += 'correct';
465 | }
466 | } else {
467 | // Character not yet typed
468 | className += 'pending';
469 | }
470 |
471 | // Handle special characters that need escaping
472 | let displayChar = char;
473 | if (char === ' ') {
474 | displayChar = '\u00A0'; // Non-breaking space
475 | } else if (char === '\n') {
476 | displayChar = ' ';
477 | } else {
478 | displayChar = escapeHtml(char);
479 | }
480 |
481 | // Add cursor class to the character at the typing position
482 | if (i === currentPosition) {
483 | className += ' cursor-position';
484 | }
485 |
486 | html += `${displayChar} `;
487 | }
488 |
489 | // If all characters are typed, add a cursor position marker at the end
490 | if (currentPosition === originalText.length) {
491 | html += '\u00A0 ';
492 | }
493 |
494 | textContainer.innerHTML = html;
495 |
496 | // Update key tip if enabled
497 | updateKeyTip();
498 | }
499 |
500 | function escapeHtml(text) {
501 | const div = document.createElement('div');
502 | div.textContent = text;
503 | return div.innerHTML;
504 | }
505 |
506 | function handleInput(e) {
507 | let input = e.target.value;
508 |
509 | // Filter out unavailable keys if availableKeys is configured
510 | if (availableKeysSet.size > 0) {
511 | let filteredInput = '';
512 | for (let i = 0; i < input.length; i++) {
513 | const char = input[i];
514 | if (isKeyAvailable(char)) {
515 | filteredInput += char;
516 | }
517 | }
518 | input = filteredInput;
519 | e.target.value = input;
520 | }
521 |
522 | // Start timer on first keypress
523 | if (startTime === null && input.length > 0) {
524 | startTime = Date.now();
525 | }
526 |
527 | // Prevent typing beyond the original text length
528 | if (input.length > originalText.length) {
529 | input = input.slice(0, originalText.length);
530 | e.target.value = input;
531 | }
532 |
533 | const inputLength = input.length;
534 | const typedLength = typedText.length;
535 |
536 | // Handle typing forward
537 | if (inputLength > typedLength) {
538 | const newChars = input.slice(typedLength);
539 | for (let i = 0; i < newChars.length; i++) {
540 | const charIndex = typedLength + i;
541 | if (charIndex >= originalText.length) {
542 | break;
543 | }
544 |
545 | const expectedChar = originalText[charIndex];
546 | const typedChar = newChars[i];
547 |
548 | totalInputs++; // Track total inputs
549 |
550 | const isError = typedChar !== expectedChar;
551 | if (isError) {
552 | charStates[charIndex] = 'incorrect';
553 | totalErrors++; // Track total errors (even if later fixed)
554 | } else {
555 | charStates[charIndex] = 'correct';
556 | }
557 |
558 | // Highlight keyboard key
559 | if (keyboardEnabled) {
560 | highlightKey(typedChar, isError);
561 | }
562 | }
563 | typedText = input;
564 | }
565 | // Handle backspace/delete
566 | else if (inputLength < typedLength) {
567 | typedText = input;
568 | // Reset states for characters that are no longer typed
569 | for (let i = inputLength; i < originalText.length; i++) {
570 | if (i < charStates.length) {
571 | charStates[i] = 'pending';
572 | }
573 | }
574 |
575 | // Highlight backspace key (only if available)
576 | if (keyboardEnabled && isKeyAvailable('backspace')) {
577 | highlightKey('backspace', false);
578 | }
579 | }
580 |
581 | renderText();
582 | updateRealtimeStats();
583 | // updateKeyTip is called in renderText, so no need to call it here
584 | }
585 |
586 | function handleKeyDown(e) {
587 | // Handle Enter key - check availability but let textarea handle insertion
588 | if (e.key === 'Enter' || e.key === 'Return') {
589 | if (!isKeyAvailable('\n')) {
590 | e.preventDefault(); // Prevent if not available
591 | return;
592 | }
593 |
594 | // Check if we can still type (not beyond original text length)
595 | if (hiddenInput.value.length >= originalText.length) {
596 | e.preventDefault(); // Can't type beyond original text
597 | return;
598 | }
599 |
600 | // Let the browser handle the newline insertion naturally
601 | // Highlight keyboard key if enabled
602 | if (keyboardEnabled) {
603 | // Use setTimeout to highlight after the newline is inserted
604 | setTimeout(() => {
605 | highlightKey('\n', false);
606 | }, 0);
607 | }
608 |
609 | // The input event will fire naturally, no need to manually trigger
610 | return;
611 | }
612 |
613 | // Handle Tab key - manually insert tab character
614 | if (e.key === 'Tab') {
615 | e.preventDefault(); // Prevent tab from moving focus
616 |
617 | if (!isKeyAvailable('\t')) {
618 | return; // Key not available, don't insert
619 | }
620 |
621 | // Check if we can still type (not beyond original text length)
622 | if (hiddenInput.value.length >= originalText.length) {
623 | return; // Can't type beyond original text
624 | }
625 |
626 | // Get current cursor position
627 | const cursorPos = hiddenInput.selectionStart || hiddenInput.value.length;
628 |
629 | // Insert tab at cursor position
630 | const currentValue = hiddenInput.value;
631 | const newValue = currentValue.slice(0, cursorPos) + '\t' + currentValue.slice(cursorPos);
632 |
633 | // Update input value
634 | hiddenInput.value = newValue;
635 |
636 | // Move cursor after the inserted tab
637 | setTimeout(() => {
638 | hiddenInput.setSelectionRange(cursorPos + 1, cursorPos + 1);
639 | }, 0);
640 |
641 | // Highlight keyboard key if enabled
642 | if (keyboardEnabled) {
643 | highlightKey('\t', false);
644 | }
645 |
646 | // Manually trigger input event to process the tab
647 | const inputEvent = new Event('input', { bubbles: true });
648 | hiddenInput.dispatchEvent(inputEvent);
649 |
650 | return;
651 | }
652 |
653 | // Prevent unavailable keys from being typed
654 | if (availableKeysSet.size > 0 && !isKeyAvailable(e.key)) {
655 | e.preventDefault();
656 | return;
657 | }
658 |
659 | // Prevent default behavior for backspace when at start
660 | if (e.key === 'Backspace' && hiddenInput.value.length === 0) {
661 | e.preventDefault();
662 | }
663 | }
664 |
665 | function restart() {
666 | typedText = '';
667 | for (let i = 0; i < charStates.length; i++) {
668 | charStates[i] = 'pending';
669 | }
670 | if (hiddenInput) {
671 | hiddenInput.value = '';
672 | }
673 |
674 | // Reset statistics
675 | startTime = null;
676 | totalErrors = 0;
677 | totalInputs = 0;
678 |
679 | // Clear real-time stats interval
680 | if (realtimeStatsInterval) {
681 | clearInterval(realtimeStatsInterval);
682 | realtimeStatsInterval = null;
683 | }
684 |
685 | // Update real-time stats display
686 | updateRealtimeStats();
687 |
688 | // Clear keyboard highlights
689 | if (activeKeyElement) {
690 | activeKeyElement.classList.remove('active', 'active-error');
691 | activeKeyElement = null;
692 | }
693 | if (activeKeyTimeout) {
694 | clearTimeout(activeKeyTimeout);
695 | activeKeyTimeout = null;
696 | }
697 |
698 | // Clear key tip if enabled
699 | if (currentTipKeyElement) {
700 | currentTipKeyElement.classList.remove('key-tip');
701 | currentTipKeyElement = null;
702 | }
703 | if (currentTipShiftElement) {
704 | currentTipShiftElement.classList.remove('key-tip');
705 | currentTipShiftElement = null;
706 | }
707 |
708 | // Show typing container and hide completion screen and stats dashboard
709 | const typingTextContainer = document.querySelector('.typing-text-container');
710 | if (typingTextContainer) {
711 | typingTextContainer.style.display = 'block';
712 | }
713 | if (completionScreen) {
714 | completionScreen.style.display = 'none';
715 | }
716 | if (statsDashboard) {
717 | statsDashboard.style.display = 'none';
718 | }
719 |
720 | // Show real-time stats again if configured
721 | if (realtimeStatsContainer) {
722 | updateRealtimeStats();
723 | }
724 |
725 | // Show keyboard-stats-wrapper again
726 | const keyboardStatsWrapper = document.querySelector('.keyboard-stats-wrapper');
727 | if (keyboardStatsWrapper) {
728 | keyboardStatsWrapper.style.display = 'block';
729 | }
730 |
731 | // Show keyboard again if it was enabled
732 | if (keyboardContainer && keyboardEnabled) {
733 | keyboardContainer.classList.add('visible');
734 | }
735 |
736 | // Show the restart button again
737 | if (restartButton && restartButton.parentElement) {
738 | restartButton.parentElement.style.display = 'block';
739 | }
740 |
741 | renderText();
742 | setStatus('Ready');
743 |
744 | // Focus the input after a short delay
745 | setTimeout(() => {
746 | if (hiddenInput) {
747 | hiddenInput.focus();
748 | }
749 | }, 50);
750 | }
751 |
752 | // Calculate real-time statistics (while typing)
753 | function calculateRealtimeStats() {
754 | // Calculate chars typed and total
755 | const charsTyped = typedText.length;
756 | const charsTotal = originalText.length;
757 |
758 | if (startTime === null) {
759 | return {
760 | speed: 0,
761 | accuracy: 0,
762 | time: 0,
763 | errors: 0,
764 | errorsLeft: 0,
765 | chars: { typed: charsTyped, total: charsTotal }
766 | };
767 | }
768 |
769 | const currentTime = Date.now();
770 | const totalTimeSeconds = (currentTime - startTime) / 1000;
771 | const totalTimeMinutes = totalTimeSeconds / 60;
772 |
773 | // Count errors left (unfixed incorrect characters)
774 | let errorsLeft = 0;
775 | for (let i = 0; i < charStates.length; i++) {
776 | if (charStates[i] === 'incorrect') {
777 | errorsLeft++;
778 | }
779 | }
780 |
781 | // Calculate accuracy: (correct inputs / total inputs) * 100
782 | const correctInputs = totalInputs - totalErrors;
783 | const accuracy = totalInputs > 0 ? (correctInputs / totalInputs) * 100 : 0;
784 |
785 | // Calculate words per minute
786 | // Count words by splitting on whitespace
787 | const wordsTyped = originalText.trim().split(/\s+/).filter(word => word.length > 0).length;
788 | const wpm = totalTimeMinutes > 0 ? wordsTyped / totalTimeMinutes : 0;
789 |
790 | return {
791 | speed: wpm,
792 | accuracy: accuracy,
793 | time: totalTimeSeconds,
794 | errors: totalErrors,
795 | errorsLeft: errorsLeft,
796 | chars: { typed: charsTyped, total: charsTotal }
797 | };
798 | }
799 |
800 | // Update real-time stats display
801 | function updateRealtimeStats() {
802 | if (!realtimeStatsContainer) return;
803 |
804 | // Check if realTimeStats is configured and has items
805 | if (!config.realTimeStats || !Array.isArray(config.realTimeStats) || config.realTimeStats.length === 0) {
806 | realtimeStatsContainer.style.display = 'none';
807 | // Clear interval if stats are disabled
808 | if (realtimeStatsInterval) {
809 | clearInterval(realtimeStatsInterval);
810 | realtimeStatsInterval = null;
811 | }
812 | return;
813 | }
814 |
815 | const stats = calculateRealtimeStats();
816 | realtimeStatsContainer.style.display = 'flex';
817 |
818 | // Clear existing content
819 | realtimeStatsContainer.innerHTML = '';
820 |
821 | // Map of stat keys to display info
822 | const statMap = {
823 | speed: { label: 'WPM', value: stats.speed, format: (v) => v.toFixed(1) },
824 | accuracy: { label: 'Accuracy', value: stats.accuracy, format: (v) => v.toFixed(1) + '%' },
825 | time: { label: 'Time', value: stats.time, format: (v) => {
826 | if (v < 60) {
827 | return v.toFixed(1) + 's';
828 | } else {
829 | const minutes = Math.floor(v / 60);
830 | const seconds = (v % 60).toFixed(1);
831 | return `${minutes}m ${seconds}s`;
832 | }
833 | }},
834 | errors: { label: 'Errors', value: stats.errors, format: (v) => Math.round(v).toString() },
835 | errorsLeft: { label: 'Errors Left', value: stats.errorsLeft, format: (v) => Math.round(v).toString() },
836 | chars: { label: 'Chars', value: stats.chars, format: (v) => `${v.typed}/${v.total}` }
837 | };
838 |
839 | // Create stat items for each configured stat
840 | config.realTimeStats.forEach(statKey => {
841 | const statInfo = statMap[statKey];
842 | if (!statInfo) return; // Skip invalid stat keys
843 |
844 | const statItem = document.createElement('div');
845 | statItem.className = 'realtime-stat-item';
846 |
847 | const statLabel = document.createElement('span');
848 | statLabel.className = 'realtime-stat-label';
849 | statLabel.textContent = statInfo.label;
850 |
851 | const statValue = document.createElement('span');
852 | statValue.className = 'realtime-stat-value';
853 | statValue.textContent = statInfo.format(statInfo.value);
854 |
855 | statItem.appendChild(statLabel);
856 | statItem.appendChild(statValue);
857 | realtimeStatsContainer.appendChild(statItem);
858 | });
859 |
860 | // Start periodic updates if typing has started and interval not already running
861 | if (startTime !== null && !realtimeStatsInterval) {
862 | realtimeStatsInterval = setInterval(() => {
863 | updateRealtimeStats();
864 | }, 100); // Update every 100ms for smooth time updates
865 | }
866 | }
867 |
868 | function calculateStatistics() {
869 | console.log('Calculating statistics...');
870 | console.log('startTime:', startTime, 'totalInputs:', totalInputs, 'totalErrors:', totalErrors);
871 |
872 | if (startTime === null) {
873 | console.log('No typing started, returning null');
874 | return null; // No typing started
875 | }
876 |
877 | const endTime = Date.now();
878 | const totalTimeSeconds = (endTime - startTime) / 1000;
879 | const totalTimeMinutes = totalTimeSeconds / 60;
880 |
881 | // Count errors left (unfixed incorrect characters)
882 | let errorsLeft = 0;
883 | for (let i = 0; i < charStates.length; i++) {
884 | if (charStates[i] === 'incorrect') {
885 | errorsLeft++;
886 | }
887 | }
888 |
889 | // Calculate accuracy: (correct inputs / total inputs) * 100
890 | const correctInputs = totalInputs - totalErrors;
891 | const accuracy = totalInputs > 0 ? (correctInputs / totalInputs) * 100 : 0;
892 |
893 | // Calculate words per minute
894 | // Count words by splitting on whitespace
895 | const wordsTyped = originalText.trim().split(/\s+/).filter(word => word.length > 0).length;
896 | const wpm = totalTimeMinutes > 0 ? wordsTyped / totalTimeMinutes : 0;
897 |
898 | const stats = {
899 | totalErrors: totalErrors,
900 | errorsLeft: errorsLeft,
901 | totalTime: totalTimeSeconds,
902 | accuracy: accuracy,
903 | speed: wpm
904 | };
905 |
906 | console.log('Calculated statistics:', stats);
907 | return stats;
908 | }
909 |
910 | async function saveStatistics(stats) {
911 | console.log('saveStatistics called with:', stats);
912 | try {
913 | // Format statistics text
914 | const statsText = `Typing Statistics
915 | ==================
916 |
917 | Total Errors Made: ${stats.totalErrors}
918 | Errors Left (Unfixed): ${stats.errorsLeft}
919 | Total Time: ${stats.totalTime.toFixed(2)} seconds
920 | Accuracy: ${stats.accuracy.toFixed(2)}%
921 | Speed: ${stats.speed.toFixed(2)} words per minute
922 |
923 | Generated: ${new Date().toLocaleString()}
924 | `;
925 |
926 | console.log('Sending stats to server:', statsText);
927 | const response = await fetch('/save-stats', {
928 | method: 'POST',
929 | headers: {
930 | 'Content-Type': 'text/plain',
931 | },
932 | body: statsText
933 | });
934 |
935 | console.log('Server response status:', response.status);
936 |
937 | if (response.ok) {
938 | const result = await response.json();
939 | console.log('Statistics saved to client/stats.txt', result);
940 | } else {
941 | const errorText = await response.text();
942 | console.error('Failed to save statistics:', response.status, errorText);
943 | }
944 | } catch (error) {
945 | console.error('Error saving statistics:', error);
946 | }
947 | }
948 |
949 | // Parse stats from stats.txt file
950 | function parseStatsText(statsText) {
951 | const stats = {};
952 | const lines = statsText.split('\n');
953 |
954 | for (const line of lines) {
955 | if (line.includes('Total Errors Made:')) {
956 | const match = line.match(/Total Errors Made:\s*(\d+)/);
957 | if (match) stats.totalErrors = parseInt(match[1], 10);
958 | } else if (line.includes('Errors Left (Unfixed):')) {
959 | const match = line.match(/Errors Left \(Unfixed\):\s*(\d+)/);
960 | if (match) stats.errorsLeft = parseInt(match[1], 10);
961 | } else if (line.includes('Total Time:')) {
962 | const match = line.match(/Total Time:\s*([\d.]+)\s*seconds/);
963 | if (match) stats.totalTime = parseFloat(match[1]);
964 | } else if (line.includes('Accuracy:')) {
965 | const match = line.match(/Accuracy:\s*([\d.]+)%/);
966 | if (match) stats.accuracy = parseFloat(match[1]);
967 | } else if (line.includes('Speed:')) {
968 | const match = line.match(/Speed:\s*([\d.]+)\s*words per minute/);
969 | if (match) stats.speed = parseFloat(match[1]);
970 | }
971 | }
972 |
973 | return stats;
974 | }
975 |
976 | // Load and display stats dashboard
977 | async function showStatsDashboard() {
978 | // Hide typing container
979 | const typingTextContainer = document.querySelector('.typing-text-container');
980 | if (typingTextContainer) {
981 | typingTextContainer.style.display = 'none';
982 | }
983 |
984 | // Hide the restart button when dashboard is shown
985 | if (restartButton && restartButton.parentElement) {
986 | restartButton.parentElement.style.display = 'none';
987 | }
988 |
989 | // Hide keyboard when dashboard is shown
990 | if (keyboardContainer) {
991 | keyboardContainer.classList.remove('visible');
992 | }
993 |
994 | // Clear key tip when dashboard is shown
995 | if (currentTipKeyElement) {
996 | currentTipKeyElement.classList.remove('key-tip');
997 | currentTipKeyElement = null;
998 | }
999 | if (currentTipShiftElement) {
1000 | currentTipShiftElement.classList.remove('key-tip');
1001 | currentTipShiftElement = null;
1002 | }
1003 |
1004 | // Hide real-time stats when dashboard is shown
1005 | if (realtimeStatsContainer) {
1006 | realtimeStatsContainer.style.display = 'none';
1007 | }
1008 |
1009 | // Hide keyboard-stats-wrapper when dashboard is shown
1010 | const keyboardStatsWrapper = document.querySelector('.keyboard-stats-wrapper');
1011 | if (keyboardStatsWrapper) {
1012 | keyboardStatsWrapper.style.display = 'none';
1013 | }
1014 |
1015 | // Hide completion screen if visible
1016 | if (completionScreen) {
1017 | completionScreen.style.display = 'none';
1018 | }
1019 |
1020 | try {
1021 | const response = await fetch('./stats.txt');
1022 | if (!response.ok) {
1023 | console.error('Failed to load stats file');
1024 | // Fall back to simple completion screen
1025 | // Keyboard is already hidden above
1026 | if (completionScreen) {
1027 | completionScreen.style.display = 'flex';
1028 | }
1029 | return;
1030 | }
1031 |
1032 | const statsText = await response.text();
1033 | const stats = parseStatsText(statsText);
1034 |
1035 | // Update dashboard with stats
1036 | const speedEl = document.getElementById('stat-speed');
1037 | const accuracyEl = document.getElementById('stat-accuracy');
1038 | const timeEl = document.getElementById('stat-time');
1039 | const errorsEl = document.getElementById('stat-errors');
1040 | const errorsLeftEl = document.getElementById('stat-errors-left');
1041 |
1042 | if (speedEl) speedEl.textContent = stats.speed ? stats.speed.toFixed(1) : '0';
1043 | if (accuracyEl) accuracyEl.textContent = stats.accuracy ? stats.accuracy.toFixed(1) + '%' : '0%';
1044 | if (timeEl) {
1045 | const timeValue = stats.totalTime || 0;
1046 | if (timeValue < 60) {
1047 | timeEl.textContent = timeValue.toFixed(1) + 's';
1048 | } else {
1049 | const minutes = Math.floor(timeValue / 60);
1050 | const seconds = (timeValue % 60).toFixed(1);
1051 | timeEl.textContent = `${minutes}m ${seconds}s`;
1052 | }
1053 | }
1054 | if (errorsEl) errorsEl.textContent = stats.totalErrors || 0;
1055 | if (errorsLeftEl) errorsLeftEl.textContent = stats.errorsLeft || 0;
1056 |
1057 | // Show dashboard
1058 | if (statsDashboard) {
1059 | statsDashboard.style.display = 'flex';
1060 | }
1061 |
1062 | if (hiddenInput) {
1063 | hiddenInput.blur();
1064 | }
1065 | } catch (error) {
1066 | console.error('Error loading stats:', error);
1067 | // Fall back to simple completion screen
1068 | // Keyboard is already hidden above
1069 | if (completionScreen) {
1070 | completionScreen.style.display = 'flex';
1071 | }
1072 | }
1073 | }
1074 |
1075 | function showCompletionScreen() {
1076 | console.log('showCompletionScreen called');
1077 |
1078 | // Hide stats dashboard if visible
1079 | if (statsDashboard) {
1080 | statsDashboard.style.display = 'none';
1081 | }
1082 |
1083 | if (!completionScreen) {
1084 | console.error('Completion screen element not found!');
1085 | return;
1086 | }
1087 |
1088 | const typingTextContainer = document.querySelector('.typing-text-container');
1089 | if (typingTextContainer) {
1090 | typingTextContainer.style.display = 'none';
1091 | }
1092 |
1093 | // Hide keyboard when completion screen is shown
1094 | if (keyboardContainer) {
1095 | keyboardContainer.classList.remove('visible');
1096 | }
1097 |
1098 | // Clear key tip when completion screen is shown
1099 | if (currentTipKeyElement) {
1100 | currentTipKeyElement.classList.remove('key-tip');
1101 | currentTipKeyElement = null;
1102 | }
1103 | if (currentTipShiftElement) {
1104 | currentTipShiftElement.classList.remove('key-tip');
1105 | currentTipShiftElement = null;
1106 | }
1107 |
1108 | // Hide real-time stats when completion screen is shown
1109 | if (realtimeStatsContainer) {
1110 | realtimeStatsContainer.style.display = 'none';
1111 | }
1112 |
1113 | // Hide keyboard-stats-wrapper when completion screen is shown
1114 | const keyboardStatsWrapper = document.querySelector('.keyboard-stats-wrapper');
1115 | if (keyboardStatsWrapper) {
1116 | keyboardStatsWrapper.style.display = 'none';
1117 | }
1118 |
1119 | // Hide the restart button when completion screen is shown
1120 | if (restartButton && restartButton.parentElement) {
1121 | restartButton.parentElement.style.display = 'none';
1122 | }
1123 |
1124 | // Calculate and save statistics
1125 | console.log('About to calculate statistics...');
1126 | const stats = calculateStatistics();
1127 | console.log('Statistics result:', stats);
1128 |
1129 | if (stats) {
1130 | console.log('Calling saveStatistics...');
1131 | saveStatistics(stats).then(() => {
1132 | // After saving, check if we should show stats dashboard
1133 | if (config.showStats === true) {
1134 | // Wait a bit for the file to be written, then show dashboard
1135 | setTimeout(() => {
1136 | showStatsDashboard();
1137 | }, 200);
1138 | } else {
1139 | // Show simple completion screen
1140 | // Ensure real-time stats are hidden
1141 | if (realtimeStatsContainer) {
1142 | realtimeStatsContainer.style.display = 'none';
1143 | }
1144 | completionScreen.style.display = 'flex';
1145 | if (hiddenInput) {
1146 | hiddenInput.blur();
1147 | }
1148 | }
1149 | });
1150 | } else {
1151 | console.log('No statistics to save (stats is null)');
1152 | // Show simple completion screen
1153 | // Ensure real-time stats are hidden
1154 | if (realtimeStatsContainer) {
1155 | realtimeStatsContainer.style.display = 'none';
1156 | }
1157 | completionScreen.style.display = 'flex';
1158 | if (hiddenInput) {
1159 | hiddenInput.blur();
1160 | }
1161 | }
1162 | }
1163 |
1164 | async function initialize() {
1165 | // Load config first
1166 | await loadConfig();
1167 |
1168 | textContainer = document.getElementById('typing-text');
1169 | hiddenInput = document.getElementById('hidden-input');
1170 | completionScreen = document.getElementById('completion-screen');
1171 | statsDashboard = document.getElementById('stats-dashboard');
1172 | restartButton = document.getElementById('btn-restart');
1173 | startOverButton = document.getElementById('btn-start-over');
1174 | statsStartOverButton = document.getElementById('btn-stats-start-over');
1175 | realtimeStatsContainer = document.getElementById('realtime-stats-container');
1176 |
1177 | if (!textContainer || !hiddenInput) {
1178 | console.error('Required elements not found');
1179 | return;
1180 | }
1181 |
1182 | // Initialize keyboard
1183 | initializeKeyboard();
1184 |
1185 | // Set up event listeners
1186 | hiddenInput.addEventListener('input', handleInput);
1187 | hiddenInput.addEventListener('keydown', handleKeyDown);
1188 |
1189 | if (restartButton) {
1190 | restartButton.addEventListener('click', restart);
1191 | }
1192 |
1193 | if (startOverButton) {
1194 | startOverButton.addEventListener('click', restart);
1195 | }
1196 |
1197 | if (statsStartOverButton) {
1198 | statsStartOverButton.addEventListener('click', restart);
1199 | }
1200 |
1201 | // Focus the input when clicking on the text container
1202 | const typingTextContainer = document.querySelector('.typing-text-container');
1203 | if (typingTextContainer) {
1204 | typingTextContainer.addEventListener('click', () => {
1205 | const isCompletionVisible = completionScreen && completionScreen.style.display === 'flex';
1206 | const isStatsVisible = statsDashboard && statsDashboard.style.display === 'flex';
1207 | if (hiddenInput && !isCompletionVisible && !isStatsVisible) {
1208 | hiddenInput.focus();
1209 | }
1210 | });
1211 | }
1212 |
1213 | // Load the text
1214 | loadText();
1215 |
1216 | // Initialize real-time stats display
1217 | updateRealtimeStats();
1218 |
1219 | // Focus the input after a short delay
1220 | setTimeout(() => {
1221 | const isCompletionVisible = completionScreen && completionScreen.style.display === 'flex';
1222 | const isStatsVisible = statsDashboard && statsDashboard.style.display === 'flex';
1223 | if (hiddenInput && !isCompletionVisible && !isStatsVisible) {
1224 | hiddenInput.focus();
1225 | }
1226 | }, 100);
1227 | }
1228 |
1229 | // Initialize when DOM is ready
1230 | if (document.readyState === 'loading') {
1231 | document.addEventListener('DOMContentLoaded', initialize);
1232 | } else {
1233 | initialize();
1234 | }
1235 | })();
1236 |
--------------------------------------------------------------------------------