Browse Source

Merge origin/master into internet-stability

Stefan Stidl 2 months ago
parent
commit
903a7024c4
70 changed files with 3868 additions and 1112 deletions
  1. 7 2
      .eslintrc.json
  2. 104 0
      .github/agents/release-agent.agent.md
  3. 25 0
      .github/workflows/docker-publish.yml
  4. 30 0
      .github/workflows/playwright.yml
  5. 2 0
      .gitignore
  6. 1 1
      .prettierrc.json
  7. 96 0
      DESIGN_SWITCH.md
  8. 7 2
      Dockerfile
  9. 7 2
      Dockerfile.alpine
  10. 8 3
      README.md
  11. BIN
      backend/country_asn.mmdb
  12. BIN
      backend/geoip2.phar
  13. 3 0
      config.json
  14. 72 0
      design-switch.js
  15. 13 25
      doc.md
  16. 12 2
      doc_docker.md
  17. 104 9
      docker/entrypoint.sh
  18. 75 8
      docker/test/docker-compose.yml
  19. 1 1
      docker/test/test-script.sh
  20. 0 528
      docker/ui.php
  21. 6 1
      examples/example-multipleServers-full.html
  22. 5 1
      examples/example-multipleServers-pretty.html
  23. 5 1
      examples/example-singleServer-customSettings.html
  24. 6 1
      examples/example-singleServer-gauges.html
  25. 5 1
      examples/example-singleServer-modern.html
  26. 5 1
      examples/example-singleServer-pretty.html
  27. 5 1
      examples/example-singleServer-progressBar.html
  28. 22 0
      frontend/README.md
  29. BIN
      frontend/fonts/Inter-latin-ext.woff2
  30. BIN
      frontend/fonts/Inter-latin.woff2
  31. BIN
      frontend/images/background-original.jpeg
  32. BIN
      frontend/images/background.jpeg
  33. 22 0
      frontend/images/chevron.svg
  34. 14 0
      frontend/images/close-button.svg
  35. 19 0
      frontend/images/favicon.svg
  36. 66 0
      frontend/images/logo.svg
  37. 151 0
      frontend/index.html
  38. 447 0
      frontend/javascript/index.js
  39. BIN
      frontend/screenshots/desktop.png
  40. BIN
      frontend/screenshots/mobile.png
  41. 409 0
      frontend/server-list.json
  42. 9 0
      frontend/settings.json
  43. 83 0
      frontend/styling/button.css
  44. 36 0
      frontend/styling/colors.css
  45. 132 0
      frontend/styling/dialog.css
  46. 22 0
      frontend/styling/fonts.css
  47. 85 0
      frontend/styling/index.css
  48. 58 0
      frontend/styling/main.css
  49. 260 0
      frontend/styling/results.css
  50. 171 0
      frontend/styling/server-selector.css
  51. 590 0
      index-classic.html
  52. 152 0
      index-modern.html
  53. 11 513
      index.html
  54. 4 1
      package.json
  55. 27 0
      playwright.config.js
  56. 6 0
      results/telemetry_mssql.sql
  57. 1 1
      speedtest.js
  58. 24 7
      stability.html
  59. 129 0
      tests/PLAYWRIGHT_MODES_PLAN.md
  60. 82 0
      tests/docker-compose-playwright.yml
  61. 16 0
      tests/e2e/classic-standalone-regression.spec.js
  62. 29 0
      tests/e2e/design-switch.spec.js
  63. 20 0
      tests/e2e/fixtures/servers-dual.json
  64. 11 0
      tests/e2e/fixtures/servers-frontend.json
  65. 39 0
      tests/e2e/global-setup.js
  66. 9 0
      tests/e2e/global-teardown.js
  67. 12 0
      tests/e2e/helpers/env.js
  68. 12 0
      tests/e2e/helpers/ui.js
  69. 59 0
      tests/e2e/modes.spec.js
  70. 25 0
      tests/e2e/title-special-chars.spec.js

+ 7 - 2
.eslintrc.json

@@ -27,10 +27,15 @@
     "addEventListener": "readonly"
   },
   "rules": {
-    "no-unused-vars": ["warn", { "args": "none" }],
+    "no-unused-vars": [
+      "warn",
+      {
+        "args": "none"
+      }
+    ],
     "no-console": "off",
     "no-empty": "warn",
     "no-undef": "warn",
     "no-const-assign": "error"
   }
-}
+}

+ 104 - 0
.github/agents/release-agent.agent.md

@@ -0,0 +1,104 @@
+---
+# Fill in the fields below to create a basic custom agent for your repository.
+# The Copilot CLI can be used for local testing: https://gh.io/customagents/cli
+# To make this agent available, merge this file into the default repository branch.
+# For format details, see: https://gh.io/customagents/config
+
+name: Release Agent
+description: prepares releases, updates release tags in code, commits release changes, and creates the release tag
+---
+
+# My Agent
+
+You are a release automation agent for this repository.
+
+## Purpose
+
+Prepare and finalize releases by analyzing repository state, updating release tags in the codebase, committing those changes, and creating the corresponding Git tag for the release.
+
+## Responsibilities
+
+- Inspect recent changes relevant to the next release
+- Summarize user-facing changes, fixes, and breaking changes
+- Draft release notes in clear markdown
+- Propose a semantic version bump with justification
+- Identify and update version or release tag references in the repository
+- Commit the release-tag changes
+- Create the release Git tag
+- Identify release risks, missing checks, and follow-up items
+- Verify that release artifacts and documentation appear consistent
+
+## Workflow
+
+1. Determine the likely release scope from recent commits, merged pull requests, and changed files
+2. Group changes into:
+   - Features
+   - Fixes
+   - Documentation
+   - Maintenance
+   - Breaking changes
+3. Recommend the next version:
+   - patch for fixes or internal changes
+   - minor for backward-compatible features
+   - major for breaking changes
+4. Identify files that contain the current version or release tag
+5. Update those files to the new release version
+6. Create a commit for the release changes
+7. Create the Git tag for the release
+8. Draft release notes with:
+   - title
+   - summary
+   - highlights
+   - breaking changes, if any
+   - migration notes, if any
+   - acknowledgments, if available
+9. Flag anything that should block or delay the release:
+   - failing or missing tests
+   - missing changelog entries
+   - undocumented breaking changes
+   - version inconsistencies
+   - incomplete release artifacts
+
+## Output Format
+
+When asked to prepare a release, respond with these sections:
+
+### Proposed Version
+`<version>` with a brief justification
+
+### Files Updated
+List of changed files and the version changes made
+
+### Release Summary
+A short paragraph describing the release
+
+### Release Notes
+Markdown-ready notes suitable for a GitHub release
+
+### Commit Message
+Proposed or created commit message
+
+### Release Tag
+Proposed or created tag name
+
+### Risks / Blockers
+Bullet list of anything that needs attention before release
+
+### Recommended Next Actions
+Short, concrete next steps
+
+### Result
+State whether the release changes were prepared only, committed, or fully tagged
+
+## Constraints
+
+- Prefer evidence from the repository over assumptions
+- Be precise and concise
+- Do not invent changes that are not supported by repository history
+- Clearly label uncertain conclusions
+- Only change files directly related to versioning or release tagging unless explicitly instructed otherwise
+- Keep version updates consistent across all affected files
+- Use semantic versioning unless the repository clearly uses a different scheme
+- Do not guess hidden release rules; infer them from the repository
+- If required files are missing or version locations are ambiguous, explain the issue clearly
+- Ask for explicit confirmation before any action that would publish or mutate release state

+ 25 - 0
.github/workflows/docker-publish.yml

@@ -24,7 +24,32 @@ env:
   IMAGE_NAME: ${{ github.repository }}
 
 jobs:
+  # Keep e2e in this workflow so Docker image build/push is hard-gated by test success.
+  # Do not move automatic e2e back to playwright.yml, otherwise master pushes run duplicate e2e jobs.
+  e2e:
+    runs-on: ubuntu-latest
+    timeout-minutes: 45
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v6
+
+      - name: Setup Node
+        uses: actions/setup-node@v6
+        with:
+          node-version: 20
+
+      - name: Install dependencies
+        run: npm install
+
+      - name: Install Playwright Chromium
+        run: npx playwright install --with-deps chromium
+
+      - name: Run Playwright tests
+        run: npm run test:e2e
+
   build:
+    needs: e2e
     runs-on: ubuntu-latest
     strategy:
       fail-fast: false

+ 30 - 0
.github/workflows/playwright.yml

@@ -0,0 +1,30 @@
+name: Playwright E2E
+
+# Manual-only by design:
+# - Automatic e2e runs that gate Docker image builds live in docker-publish.yml.
+# - Keeping this workflow manual avoids running the same e2e suite twice on master pushes.
+on:
+  workflow_dispatch:
+
+jobs:
+  e2e:
+    runs-on: ubuntu-latest
+    timeout-minutes: 45
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v6
+
+      - name: Setup Node
+        uses: actions/setup-node@v6
+        with:
+          node-version: 20
+
+      - name: Install dependencies
+        run: npm install
+
+      - name: Install Playwright Chromium
+        run: npx playwright install --with-deps chromium
+
+      - name: Run Playwright tests
+        run: npm run test:e2e

+ 2 - 0
.gitignore

@@ -4,6 +4,8 @@ db-dir/
 .vscode/
 node_modules/
 package-lock.json
+playwright-report/
+test-results/
 results/speedtest_telemetry.db
 results/speedtest_telemetry.db-shm
 results/speedtest_telemetry.db-wal

+ 1 - 1
.prettierrc.json

@@ -9,4 +9,4 @@
   "arrowParens": "avoid",
   "endOfLine": "lf",
   "htmlWhitespaceSensitivity": "ignore"
-}
+}

+ 96 - 0
DESIGN_SWITCH.md

@@ -0,0 +1,96 @@
+# Design Feature Switch
+
+LibreSpeed now supports switching between the classic design and the new modern design.
+
+## Default Behavior
+
+By default, LibreSpeed uses the **classic design** (located in `index-classic.html`).
+
+## Architecture
+
+### File Structure (Non-Docker)
+- **`index.html`** - Entry point (lightweight switcher)
+- **`index-classic.html`** - Classic design at root
+- **`index-modern.html`** - Modern design at root (references assets in subdirectories)
+- **`frontend/`** - Directory containing modern design assets (CSS, JS, images, fonts) - kept for non-Docker deployments
+
+### File Structure (Docker)
+In Docker deployments, the frontend assets are flattened to root-level subdirectories:
+- **`index.html`** - Entry point (lightweight switcher)
+- **`index-classic.html`** - Classic design
+- **`index-modern.html`** - Modern design  
+- **`styling/`** - CSS files for modern design
+- **`javascript/`** - JS files for modern design
+- **`images/`** - Images for modern design
+- **`fonts/`** - Fonts for modern design
+- **No `frontend/` directory** - Assets are copied directly to root subdirectories
+
+### Benefits of Root-Level Design Files
+✅ Both designs at same level - no path confusion
+✅ `results/` accessible from both designs with same relative path
+✅ `backend/` accessible from both designs with same relative path  
+✅ No subdirectory nesting issues
+✅ Clean separation of concerns
+✅ Docker containers have no `frontend/` parent directory
+
+## Browser Compatibility
+
+The feature switch uses modern JavaScript features (URLSearchParams, XMLHttpRequest). It is compatible with all modern browsers. The new design itself requires modern browser features and has no backwards compatibility with older browsers (see `frontend/README.md`).
+
+## Enabling the New Design
+
+There are two ways to enable the new design:
+
+### Method 1: Configuration File (Persistent)
+
+Edit the `config.json` file in the root directory and set `useNewDesign` to `true`:
+
+```json
+{
+  "useNewDesign": true
+}
+```
+
+This will make the new design the default for all users visiting your site.
+
+### Method 2: URL Parameter (Temporary Override)
+
+You can override the configuration by adding a URL parameter:
+
+- To use the new design: `http://yoursite.com/?design=new`
+- To use the classic design: `http://yoursite.com/?design=classic` or ?design=old
+
+URL parameters take precedence over the configuration file, making them useful for testing or allowing users to choose their preferred design.
+
+## Design Locations
+
+### Non-Docker Deployments
+- **Entry Point**: Root `index.html` file (lightweight redirect page)
+- **Old Design**: `index-classic.html` at root
+- **New Design**: `index-modern.html` at root (references assets in `frontend/` subdirectory)
+- **Assets**: Frontend assets (CSS, JS, images, fonts) in `frontend/` subdirectory
+
+### Docker Deployments
+- **Entry Point**: Root `index.html` file (lightweight redirect page)
+- **Old Design**: `index-classic.html` at root
+- **New Design**: `index-modern.html` at root (references assets in root subdirectories)
+- **Assets**: Frontend assets copied directly to root subdirectories (`styling/`, `javascript/`, `images/`, `fonts/`)
+- **No `frontend/` directory** - Assets are flattened to root level
+
+Both designs are at the same directory level, ensuring that relative paths to shared resources like `backend/` and `results/` work correctly for both.
+
+## Technical Details
+
+The feature switch is implemented in `design-switch.js`, which is loaded by the root `index.html`. It checks:
+
+1. First, URL parameters (`?design=new` or `?design=old`)
+2. Then, the `config.json` configuration file
+3. Redirects to either `index-classic.html` or `index-modern.html`
+
+Both design HTML files are at the root level, eliminating path issues.
+
+### Non-Docker
+The modern design references assets from the `frontend/` subdirectory (e.g., `frontend/styling/index.css`), while both designs can access shared resources like `backend/` and `results/` using the same relative paths.
+
+### Docker
+In Docker deployments, the `frontend/` directory is flattened during container startup. Assets are copied directly to root-level subdirectories (`styling/`, `javascript/`, `images/`, `fonts/`), and `index-modern.html` references these root-level paths. This eliminates the `frontend/` parent directory in the container.

+ 7 - 2
Dockerfile

@@ -15,27 +15,32 @@ RUN mkdir -p /speedtest/
 
 # Copy sources
 COPY backend/ /speedtest/backend
+COPY frontend/ /speedtest/frontend
 
 COPY results/*.php /speedtest/results/
 COPY results/*.ttf /speedtest/results/
 
 COPY *.js /speedtest/
 COPY stability.html /speedtest/
+COPY index.html /speedtest/
+COPY index-classic.html /speedtest/
+COPY index-modern.html /speedtest/
+COPY config.json /speedtest/
 COPY favicon.ico /speedtest/
 
-COPY docker/servers.json /servers.json
-
 COPY docker/*.php /speedtest/
 COPY docker/entrypoint.sh /
 
 # Prepare default environment variables
 ENV TITLE=LibreSpeed
+ENV TAGLINE="No Flash, No Java, No Websockets, No Bullsh*t"
 ENV MODE=standalone
 ENV PASSWORD=password
 ENV TELEMETRY=false
 ENV ENABLE_ID_OBFUSCATION=false
 ENV REDACT_IP_ADDRESSES=false
 ENV WEBPORT=8080
+ENV USE_NEW_DESIGN=false
 
 # https://httpd.apache.org/docs/2.4/stopping.html#gracefulstop
 STOPSIGNAL SIGWINCH

+ 7 - 2
Dockerfile.alpine

@@ -29,27 +29,32 @@ RUN mkdir -p /speedtest/
 
 # Copy sources
 COPY backend/ /speedtest/backend
+COPY frontend/ /speedtest/frontend
 
 COPY results/*.php /speedtest/results/
 COPY results/*.ttf /speedtest/results/
 
 COPY *.js /speedtest/
 COPY stability.html /speedtest/
+COPY index.html /speedtest/
+COPY index-classic.html /speedtest/
+COPY index-modern.html /speedtest/
+COPY config.json /speedtest/
 COPY favicon.ico /speedtest/
 
-COPY docker/servers.json /servers.json
-
 COPY docker/*.php /speedtest/
 COPY docker/entrypoint.sh /
 
 # Prepare default environment variables
 ENV TITLE=LibreSpeed
+ENV TAGLINE="No Flash, No Java, No Websockets, No Bullsh*t"
 ENV MODE=standalone
 ENV PASSWORD=password
 ENV TELEMETRY=false
 ENV ENABLE_ID_OBFUSCATION=false
 ENV REDACT_IP_ADDRESSES=false
 ENV WEBPORT=8080
+ENV USE_NEW_DESIGN=false
 
 # https://httpd.apache.org/docs/2.4/stopping.html#gracefulstop
 STOPSIGNAL SIGWINCH

+ 8 - 3
README.md

@@ -26,7 +26,7 @@ Works with mobile versions too.
 * Results sharing (optional)
 * Multiple Points of Test (optional)
 
-![Screenrecording of a running Speedtest](https://speedtest.fdossena.com/mpot_v6.gif)
+![Screenrecording of a running Speedtest](https://speedtest.fdossena.com/mpot_v7.gif)
 
 ## Server requirements
 
@@ -40,9 +40,10 @@ Works with mobile versions too.
 Assuming you have PHP and a web server installed, the installation steps are quite simple.
 
 1. Download the source code and extract it
-1. Copy the following files to your web server's shared folder (ie. /var/www/html/speedtest for Apache): index.html, speedtest.js, speedtest_worker.js, favicon.ico and the backend folder
+1. Copy the project files to your web server's shared folder (ie. `/var/www/html/speedtest` for Apache). For the current layout, the web root should contain `index.html`, `index-classic.html`, `index-modern.html`, `design-switch.js`, `config.json`, `speedtest.js`, `speedtest_worker.js`, `favicon.ico`, and the `backend` folder.
+1. Also copy the contents of `frontend/` into the same web root so the modern UI assets end up in `styling/`, `javascript/`, `images/`, and `fonts/` next to the HTML files.
 1. Optionally, copy the results folder too, and set up the database using the config file in it.
-1. Be sure your permissions allow execute (755).
+1. Be sure your permissions allow read and execute access where needed.
 1. Visit YOURSITE/speedtest/index.html and voila!
 
 ### Installation Video
@@ -67,6 +68,10 @@ A .NET client library is available in the [`LibreSpeed.NET`](https://github.com/
 
 If you want to contribute or develop with LibreSpeed, see [DEVELOPMENT.md](DEVELOPMENT.md) for information about using npm for development tasks, linting, and formatting.
 
+## Design switch
+
+LibreSpeed supports both the classic and modern UI. The root `index.html` acts as a lightweight switcher and redirects to `index-classic.html` or `index-modern.html` based on `config.json` (`useNewDesign`) or URL overrides (`?design=new` / `?design=old`). For architecture and deployment details (including Docker behavior), see [DESIGN_SWITCH.md](DESIGN_SWITCH.md).
+
 ## Docker
 
 A docker image is available on [GitHub](https://github.com/librespeed/speedtest/pkgs/container/speedtest), check our [docker documentation](doc_docker.md) for more info about it.

BIN
backend/country_asn.mmdb


BIN
backend/geoip2.phar


+ 3 - 0
config.json

@@ -0,0 +1,3 @@
+{
+  "useNewDesign": false
+}

+ 72 - 0
design-switch.js

@@ -0,0 +1,72 @@
+/**
+ * Feature switch for enabling the new LibreSpeed design
+ * 
+ * This script checks for:
+ * 1. URL parameter: ?design=new or ?design=old
+ * 2. Configuration file: config.json with useNewDesign flag
+ * 
+ * Default behavior: Shows the old design
+ * 
+ * Note: This script is only loaded on the root index.html
+ */
+(function () {
+    'use strict';
+
+    // Don't run this script if we're already on a specific design page
+    // This prevents infinite redirect loops
+    const currentPath = window.location.pathname;
+    if (currentPath.includes('index-classic.html') || currentPath.includes('index-modern.html')) {
+        return;
+    }
+
+    // Check URL parameters first (they override config)
+    const urlParams = new URLSearchParams(window.location.search);
+    const designParam = urlParams.get('design');
+
+    if (designParam === 'new') {
+        redirectToNewDesign();
+        return;
+    }
+
+    if (designParam === 'old' || designParam === 'classic') {
+        redirectToOldDesign();
+        return;
+    }
+
+    // Check config.json for design preference
+    try {
+        const xhr = new XMLHttpRequest();
+        // Use a synchronous request to prevent a flash of the old design before redirecting
+        xhr.open('GET', 'config.json', false);
+        xhr.send(null);
+
+        // Check for a successful response, but not 304 Not Modified, which can have an empty response body
+        if (xhr.status >= 200 && xhr.status < 300) {
+            const config = JSON.parse(xhr.responseText);
+            if (config.useNewDesign === true) {
+                redirectToNewDesign();
+            } else {
+                redirectToOldDesign();
+            }
+        } else {
+            // Config not found or error - default to old design
+            redirectToOldDesign();
+        }
+    } catch (error) {
+        // If there's any error (e.g., network, JSON parse), default to old design
+        console.log('Using default (old) design:', error.message || 'config error');
+        redirectToOldDesign();
+    }
+
+    function redirectToNewDesign() {
+        // Preserve any URL parameters when redirecting
+        const currentParams = window.location.search;
+        window.location.href = 'index-modern.html' + currentParams;
+    }
+
+    function redirectToOldDesign() {
+        // Preserve any URL parameters when redirecting
+        const currentParams = window.location.search;
+        window.location.href = 'index-classic.html' + currentParams;
+    }
+})();

+ 13 - 25
doc.md

@@ -1,7 +1,7 @@
 # LibreSpeed
 
 > by Federico Dossena
-> Version 5.5.1
+> Version 6.1.0
 > [https://github.com/librespeed/speedtest/](https://github.com/librespeed/speedtest/)
 
 ## Introduction
@@ -83,23 +83,7 @@ Log into your database using phpMyAdmin or a similar software and create a new d
 
 Open `results/telemetry_settings.php` in a text editor. Set `$db_type` to either `mysql`,`postgresql`, `mssql` or `sqlite`.
 
-If you chose to use SQLite, the default configuration stores the database at `__DIR__ . '/../../speedtest_telemetry.db'`, which places it two directories up from the `results/` folder. This is designed to keep the database **outside your web-accessible directory** for security.
-
-**Critical Security Requirements**:
-1. **Web Server Configuration**: Configure your web server's document root to point to the application directory, NOT its parent. For example:
-   - Install application files to: `/var/www/speedtest/`
-   - Set Apache/nginx document root to: `/var/www/speedtest/` (NOT `/var/www/`)
-   - Database will be at: `/var/www/speedtest_telemetry.db` (outside document root, not web-accessible)
-
-2. **Alternative: Use Absolute Path**: For maximum security, especially if you cannot control the document root configuration, modify `$Sqlite_db_file` in `results/telemetry_settings.php` to use an absolute path completely outside your web directories:
-   ```php
-   $Sqlite_db_file = '/var/lib/speedtest/speedtest_telemetry.db';  // or /opt/speedtest_data/speedtest_telemetry.db
-   ```
-   Ensure the web server has write permissions to this directory.
-
-3. **Verification**: After running at least one speed test or accessing `sanitycheck.php` (which creates the database), try accessing `http://yourserver/speedtest_telemetry.db` in a browser - you should get a 404 error. If the file downloads, your configuration is insecure. Note: The database file won't exist until the first test is recorded, so you'll get a 404 initially even with correct configuration.
-
-SQLite doesn't require any additional configuration beyond setting a secure path and ensuring proper permissions.
+If you chose to use SQLite, you might want to change `$Sqlite_db_file` to another path where you want the database to be stored. Just make sure that the file cannot be downloaded by users. Sqlite doesn't require any additional configuration, you can skip the rest of this section.
 
 If you chose to use MySQL, you must set your database credentials:
 
@@ -190,18 +174,25 @@ Requirements:
   * FreeType 2 and its PHP module
   * The PHP gd library
 
-To install the speed test frontend, copy the following files to your web server:
+To install the speed test frontend, copy the project files to your web server and keep the modern UI assets next to the HTML files:
 
+* `index.html`
+* `index-classic.html`
+* `index-modern.html`
+* `design-switch.js`
+* `config.json`
 * `speedtest.js`
 * `speedtest_worker.js`
+* `favicon.ico`
+* the `backend` folder
+* the contents of `frontend/`, copied so `styling/`, `javascript/`, `images/`, and `fonts/` sit next to the HTML files
 * Optionally, the `results` folder
-* `index.html` (or one of the example UIs in the `examples` folder)
 
-__Important:__ The speed test needs write permissions in the installation folder!
+__Important:__ The speed test needs read and execute permissions in the installation folder where applicable!
 
 ##### Server list
 
-Edit `index.html` and uncomment the list of servers:
+Edit `index-classic.html` and uncomment the list of servers:
 
 ```js
 var SPEEDTEST_SERVERS=[
@@ -872,9 +863,6 @@ s.setParameter("test_order","P_D_U");
 
 This will point to our static files and set the test to only do ping/jitter, download and upload tests.
 
-There is also an example to achieve this with nginx backend here:
-https://github.com/librespeed/speedtest/issues/375#issuecomment-3769211254
-
 ## Troubleshooting
 
 These are the most common issues reported by users, and how to fix them. If you still need help, contact me at [info@fdossena.com](mailto:info@fdossena.com).

+ 12 - 2
doc_docker.md

@@ -33,11 +33,12 @@ services:
     environment:
       MODE: standalone
       #TITLE: "LibreSpeed"
+      #TAGLINE: "No Flash, No Java, No Websockets, No Bullsh*t"
       #TELEMETRY: "false"
       #ENABLE_ID_OBFUSCATION: "false"
       #REDACT_IP_ADDRESSES: "false"
       #PASSWORD:
-      #EMAIL:
+      #GDPR_EMAIL: "privacy@example.com"
       #DISABLE_IPINFO: "false"
       #IPINFO_APIKEY: "your api key"
       #DISTANCE: "km"
@@ -57,6 +58,9 @@ The test can be accessed on port 80.
 Here's a list of additional environment variables available in this mode:
 
 * __`TITLE`__: Title of your speed test. Default value: `LibreSpeed`
+* __`TAGLINE`__: Slogan shown below the heading on the modern frontend (`index-modern.html`). Default value: `No Flash, No Java, No Websockets, No Bullsh*t`
+* __`USE_NEW_DESIGN`__: When set to `true`, enables the new modern frontend design. When set to `false` (default), uses the classic design. The design can also be switched using URL parameters (`?design=new` or `?design=old`). Default value: `false`
+* __`SERVER_LIST_URL`__: When set, both frontend designs load their server list from this URL instead of the generated or mounted `server-list.json`. This is useful if you want the containerized frontend to consume a remote shared server list.
 * __`TELEMETRY`__: Whether to enable telemetry or not. If enabled, you maybe want your data to be persisted. See below. Default value: `false`
 * __`ENABLE_ID_OBFUSCATION`__: When set to true with telemetry enabled, test IDs are obfuscated, to avoid exposing the database internal sequential IDs. Default value: `false`
 * __`OBFUSCATION_SALT`__: The salt string that is used to obfuscate the test IDs. The format shoud be a 2 byte hex string (e.g. `0x1234abcd`). If not specified, a random one will be generated.
@@ -70,7 +74,7 @@ Here's a list of additional environment variables available in this mode:
     * DB_USERNAME, DB_PASSWORD - credentials of the user with read and update permissions to the db
   * mssql - not supported in docker image yet (feel free to open a PR with that, has to be done in `entrypoint.sh`)
 * __`PASSWORD`__: Password to access the stats page. If not set, stats page will not allow accesses.
-* __`EMAIL`__: Email address for GDPR requests. Must be specified when telemetry is enabled.
+* __`GDPR_EMAIL`__: Email address displayed in the privacy policy for data deletion requests. If not set, the default placeholder text will be shown. This should be set to comply with GDPR requirements when running in production. Must be specified when telemetry is enabled. Note: the old `EMAIL` environment variable is still accepted as a fallback but is deprecated — please migrate to `GDPR_EMAIL`.
 * __`DISABLE_IPINFO`__: If set to `true`, ISP info and distance will not be fetched from either [ipinfo.io](https://ipinfo.io) or the offline database. Default: value: `false`
 * __`IPINFO_APIKEY`__: API key for [ipinfo.io](https://ipinfo.io). Optional, but required if you want to use the full [ipinfo.io](https://ipinfo.io) APIs (required for distance measurement)
 * __`DISTANCE`__: When `DISABLE_IPINFO` is set to false, this specifies how the distance from the server is measured. Can be either `km` for kilometers, `mi` for miles, or an empty string to disable distance measurement. Requires an [ipinfo.io](https://ipinfo.io) API key. Default value: `km`
@@ -150,6 +154,12 @@ The test can be accessed on port 80.
 
 The list of environment variables available in this mode is the same as [above in standalone mode](#standalone-mode).
 
+If you want the Docker frontend to load its server list from another URL instead of `/servers.json`, set `SERVER_LIST_URL`:
+
+```shell
+docker run -e MODE=frontend -e SERVER_LIST_URL="https://example.com/custom-server-list.json" -p 80:8080 -it ghcr.io/librespeed/speedtest
+```
+
 #### Example Frontend mode
 
 This command starts LibreSpeed in frontend mode, with a given `servers.json` file, and with telemetry, ID obfuscation, and a stats password and a persistant sqlite database for results:

+ 104 - 9
docker/entrypoint.sh

@@ -1,12 +1,35 @@
 #!/bin/bash
 
+echo "Setting up docker env..."
+echo "MODE: $MODE"
+echo "USE_NEW_DESIGN: $USE_NEW_DESIGN"
+echo "SERVER_LIST_URL: $SERVER_LIST_URL"
+echo "WEBPORT: $WEBPORT"
+echo "REDACT_IP_ADDRESSES: $REDACT_IP_ADDRESSES"
+echo "DB_TYPE: $DB_TYPE"
+echo "ENABLE_ID_OBFUSCATION: $ENABLE_ID_OBFUSCATION"
+echo "GDPR_EMAIL: $GDPR_EMAIL"
+
 set -e
-set -x
+#set -x
 
 is_alpine() {
   [ -f /etc/alpine-release ]
 }
 
+html_escape() {
+  printf '%s' "$1" | sed \
+    -e 's/&/\&amp;/g' \
+    -e 's/</\&lt;/g' \
+    -e 's/>/\&gt;/g' \
+    -e 's/"/\&quot;/g' \
+    -e "s/'/\&#39;/g"
+}
+
+sed_escape() {
+  printf '%s\n' "$1" | sed 's/[&/\\]/\\&/g; s/\$/\\$/g'
+}
+
 # Cleanup
 rm -rf /var/www/html/*
 
@@ -14,6 +37,10 @@ rm -rf /var/www/html/*
 cp /speedtest/*.js /var/www/html/
 cp /speedtest/stability.html /var/www/html/
 
+# Copy design switch files
+cp /speedtest/config.json /var/www/html/
+cp /speedtest/design-switch.js /var/www/html/
+
 # Copy favicon
 cp /speedtest/favicon.ico /var/www/html/
 
@@ -26,11 +53,6 @@ else
 fi
 
 
-# Copy servers.json for stability page (frontend/dual modes)
-if [[ "$MODE" == "frontend" || "$MODE" == "dual" ]]; then
-  cp /servers.json /var/www/html/servers.json
-fi
-
 # Set up backend side for standlone modes
 if [[ "$MODE" == "standalone" || "$MODE" == "dual" ]]; then
   cp -r /speedtest/backend/ /var/www/html/backend
@@ -46,14 +68,87 @@ if [ "$MODE" == "backend" ]; then
   fi
 fi
 
-# Set up unified index.php
-if [ "$MODE" != "backend" ]; then
-  cp /speedtest/ui.php /var/www/html/index.php
+# Set up index.php for frontend-only or standalone modes
+if [[ "$MODE" == "frontend" || "$MODE" == "dual" ||  "$MODE" == "standalone" ]]; then
+  # Copy design files (switcher + both designs)
+  cp /speedtest/index.html /var/www/html/
+  cp /speedtest/index-classic.html /var/www/html/
+  cp /speedtest/index-modern.html /var/www/html/
+  cp /speedtest/stability.html /var/www/html/
+  # Copy frontend assets directly to root-level subdirectories (no frontend/ parent dir)
+  mkdir -p /var/www/html/styling /var/www/html/javascript /var/www/html/images /var/www/html/fonts
+  cp -a /speedtest/frontend/styling/* /var/www/html/styling/
+  cp -a /speedtest/frontend/javascript/* /var/www/html/javascript/
+  cp -a /speedtest/frontend/images/* /var/www/html/images/
+  cp -a /speedtest/frontend/fonts/* /var/www/html/fonts/ 2>/dev/null || true
+  # Copy frontend config files
+  cp /speedtest/frontend/settings.json /var/www/html/settings.json 2>/dev/null || true
+  if [ -f /servers.json ]; then
+    echo "using mounted /servers.json for server-list.json"
+    cp /servers.json /var/www/html/server-list.json
+  else
+    echo "no /servers.json found, create one for local host"
+    # generate config for just the local server
+    echo '[{"name":"local","server":"/backend",  "dlURL": "garbage.php", "ulURL": "empty.php", "pingURL": "empty.php", "getIpURL": "getIP.php", "sponsorName": "", "sponsorURL": "", "id":1 }]' > /var/www/html/server-list.json
+  fi
+  if [ ! -z "$SERVER_LIST_URL" ]; then
+    echo "using SERVER_LIST_URL for frontend server list"
+    SERVER_LIST_URL_ESCAPED=$(printf '%s\n' "$SERVER_LIST_URL" | sed 's/[&/\\]/\\&/g; s/\$/\\$/g')
+    sed -i "s/var SPEEDTEST_SERVERS = \"server-list.json\";/var SPEEDTEST_SERVERS = \"$SERVER_LIST_URL_ESCAPED\";/" /var/www/html/index-modern.html
+    sed -i "s/var SPEEDTEST_SERVERS = \\[/var SPEEDTEST_SERVERS = \"$SERVER_LIST_URL_ESCAPED\";\\n\\t\\t\\/\\*/" /var/www/html/index-classic.html
+  fi
+
+  # The stability page reads the same local server list as the main UI when present.
+  if [ -f /var/www/html/server-list.json ]; then
+    cp /var/www/html/server-list.json /var/www/html/servers.json
+  fi
+
+  # Replace title placeholders if TITLE is set
+  if [ ! -z "$TITLE" ]; then
+    TITLE_ONE_LINE=${TITLE//$'\r'/}
+    TITLE_ONE_LINE=${TITLE_ONE_LINE//$'\n'/ }
+    TITLE_HTML_ESCAPED=$(html_escape "$TITLE_ONE_LINE")
+    TITLE_ESCAPED=$(sed_escape "$TITLE_HTML_ESCAPED")
+    sed -i "s/<title>LibreSpeed<\\/title>/<title>$TITLE_ESCAPED<\\/title>/g; s/<h1>LibreSpeed<\\/h1>/<h1>$TITLE_ESCAPED<\\/h1>/g" /var/www/html/index-classic.html
+    sed -i "s/<title>LibreSpeed<\\/title>/<title>$TITLE_ESCAPED<\\/title>/g" /var/www/html/index.html
+    sed -i "s/<title>LibreSpeed - Free and Open Source Speedtest<\\/title>/<title>$TITLE_ESCAPED - Free and Open Source Speedtest<\\/title>/g; s/<h1>Free and Open Source Speedtest\\.<\\/h1>/<h1>$TITLE_ESCAPED<\\/h1>/g" /var/www/html/index-modern.html
+  fi
+
+  # Replace modern page tagline if TAGLINE is set
+  if [ -n "$TAGLINE" ]; then
+    TAGLINE_ONE_LINE=${TAGLINE//$'\r'/}
+    TAGLINE_ONE_LINE=${TAGLINE_ONE_LINE//$'\n'/ }
+    TAGLINE_HTML_ESCAPED=$(html_escape "$TAGLINE_ONE_LINE")
+    TAGLINE_ESCAPED=$(sed_escape "$TAGLINE_HTML_ESCAPED")
+    sed -i "s/<p class=\"tagline\">No Flash, No Java, No Websockets, No Bullsh\\*t<\\/p>/<p class=\"tagline\">$TAGLINE_ESCAPED<\\/p>/g" /var/www/html/index-modern.html
+  fi
+  # Support legacy EMAIL env var as fallback for GDPR_EMAIL
+  if [ -z "$GDPR_EMAIL" ] && [ ! -z "$EMAIL" ]; then
+    echo "WARNING: EMAIL env var is deprecated, please use GDPR_EMAIL instead" >&2
+    GDPR_EMAIL="$EMAIL"
+    echo "GDPR_EMAIL: $GDPR_EMAIL"
+  fi
+
+  # Replace GDPR email placeholder if GDPR_EMAIL is set
+  if [ ! -z "$GDPR_EMAIL" ]; then
+    # Escape special sed characters: & (replacement), / (delimiter), \ (escape), $ (variable)
+    GDPR_EMAIL_ESCAPED=$(printf '%s\n' "$GDPR_EMAIL" | sed 's/[&/\\]/\\&/g; s/\$/\\$/g')
+    for html_file in /var/www/html/index-modern.html /var/www/html/index-classic.html; do
+      if [ -f "$html_file" ]; then
+        sed -i "s/TO BE FILLED BY DEVELOPER/$GDPR_EMAIL_ESCAPED/g; s/PUT@YOUR_EMAIL.HERE/$GDPR_EMAIL_ESCAPED/g" "$html_file"
+      fi
+    done
+  fi
+fi
+# Configure design preference via config.json
+if [ "$USE_NEW_DESIGN" == "true" ]; then
+  sed -i 's/"useNewDesign": false/"useNewDesign": true/' /var/www/html/config.json
 fi
 
 # Apply Telemetry settings when running in standalone or frontend mode and telemetry is enabled
 if [[ "$TELEMETRY" == "true" && ("$MODE" == "frontend" || "$MODE" == "standalone" || "$MODE" == "dual") ]]; then
   cp -r /speedtest/results /var/www/html/results
+  sed -i 's/telemetry_level": ".*"/telemetry_level": "basic"/' /var/www/html/settings.json
 
   if [ "$MODE" == "frontend" ]; then
     mkdir /var/www/html/backend

+ 75 - 8
docker/test/docker-compose.yml

@@ -4,14 +4,14 @@ x-shared:
   speedtest-service: &speedtest-service
     environment:
       - PASSWORD=gimmeTheStats1337
-      - EMAIL=test@example.com
+      - GDPR_EMAIL=test@example.com
       - ENABLE_ID_OBFUSCATION=true
       - MODE=standalone
       - REDACT_IP_ADDRESSES=true
       - TELEMETRY=true
 
 services:
-###################### POSTGRESQL ################################
+  ###################### POSTGRESQL ################################
   pg:
     image: postgres:alpine
     environment:
@@ -32,7 +32,7 @@ services:
       - DB_PASSWORD=Not4SecureProduction
       - DB_TYPE=postgresql
       - DB_USERNAME=librespeed
-      - EMAIL=test@example.com
+      - GDPR_EMAIL=test@example.com
       - ENABLE_ID_OBFUSCATION=true
       - MODE=standalone
       - REDACT_IP_ADDRESSES=true
@@ -48,7 +48,7 @@ services:
     ports:
       - 9124:8080
 
-####################### MYSQL ##############################
+  ####################### MYSQL ##############################
 
   mysql:
     image: mysql:lts
@@ -73,7 +73,7 @@ services:
       - DB_PASSWORD=Not4SecureProduction
       - DB_TYPE=mysql
       - DB_USERNAME=librespeed
-      - EMAIL=test@example.com
+      - GDPR_EMAIL=test@example.com
       - ENABLE_ID_OBFUSCATION=true
       - MODE=standalone
       - REDACT_IP_ADDRESSES=true
@@ -88,7 +88,72 @@ services:
     ports:
       - 9126:8080
 
-################ SQLITE ####################################
+  ####################### MSSQL ##############################
+
+  mssql:
+    image: mcr.microsoft.com/mssql/server:2022-latest
+    environment:
+      - ACCEPT_EULA=Y
+      - SA_PASSWORD=Not4SecureProduction1337
+      - MSSQL_PID=Express
+    healthcheck:
+      test:
+        ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P $$SA_PASSWORD -C -Q 'SELECT 1' || exit 1"]
+      interval: 10s
+      retries: 5
+      start_period: 30s
+
+  mssql-init:
+    image: mcr.microsoft.com/mssql/server:2022-latest
+    depends_on:
+      mssql:
+        condition: service_healthy
+    environment:
+      - SA_PASSWORD=Not4SecureProduction1337
+    volumes:
+      - ../../results/telemetry_mssql.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
+    command: >
+      bash -c '
+        echo "Waiting for MSSQL to be ready...";
+        until /opt/mssql-tools18/bin/sqlcmd -S mssql -U sa -P "$$SA_PASSWORD" -C -Q "SELECT 1"; do
+          sleep 1;
+        done;
+        echo "Running init scripts...";
+        for f in /docker-entrypoint-initdb.d/*.sql; do
+          echo "Executing $$f...";
+          /opt/mssql-tools18/bin/sqlcmd -S mssql -U sa -P "$$SA_PASSWORD" -C -i "$$f";
+        done;
+        echo "Initialization complete!";
+      '
+
+  speedtest-debian-mssql:
+    build:
+      context: ../..
+      dockerfile: Dockerfile
+    environment: &env_vars_mssql
+      - PASSWORD=gimmeTheStats1337
+      - DB_HOSTNAME=mssql
+      - DB_NAME=librespeed
+      - DB_PASSWORD=Not4SecureProduction1337
+      - DB_TYPE=mssql
+      - DB_USERNAME=sa
+      - GDPR_EMAIL=test@example.com
+      - ENABLE_ID_OBFUSCATION=true
+      - MODE=standalone
+      - REDACT_IP_ADDRESSES=true
+      - TELEMETRY=true
+    ports:
+      - 9130:8080
+
+  speedtest-alpine-mssql:
+    build:
+      context: ../..
+      dockerfile: Dockerfile.alpine
+    environment: *env_vars_mssql
+    ports:
+      - 9131:8080
+
+  ################ SQLITE ####################################
 
   speedtest-debian-sqlite:
     # check at http://localhost:9125/results/sanitycheck.php
@@ -97,7 +162,7 @@ services:
       dockerfile: Dockerfile
     environment: &env_vars_sqlite
       - PASSWORD=gimmeTheStats1337
-      - EMAIL=test@example.com
+      - GDPR_EMAIL=test@example.com
       - ENABLE_ID_OBFUSCATION=true
       - MODE=standalone
       - REDACT_IP_ADDRESSES=true
@@ -126,14 +191,16 @@ services:
     ports:
       - 9129:8080
 
-##############  TEST CONTAINER  ###############################################################
+  ##############  TEST CONTAINER  ###############################################################
   test-container:
     image: alpine
     depends_on:
       - pg
       - mysql
+      - mssql-init
       - speedtest-alpine-mysql
       - speedtest-alpine-pg
+      - speedtest-alpine-mssql
     volumes:
       - ./test-script.sh:/test-script.sh
     command:

+ 1 - 1
docker/test/test-script.sh

@@ -12,7 +12,7 @@ fi
 echo sleeping a little to get things settled...
 sleep 15
 
-for db in sqlite pg mysql; do
+for db in sqlite pg mysql mssql; do
   for distro in alpine debian; do
 	hostname=speedtest-$distro-$db
 	echo $hostname

+ 0 - 528
docker/ui.php

@@ -1,528 +0,0 @@
-<!DOCTYPE html>
-<html>
-<head>
-<link rel="shortcut icon" href="favicon.ico">
-<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no" />
-<meta charset="UTF-8" />
-<script type="text/javascript" src="speedtest.js"></script>
-<script type="text/javascript">
-function I(i){return document.getElementById(i);}
-
-//LIST OF TEST SERVERS. See documentation for details if needed
-<?php
-$mode=getenv("MODE");
-if($mode=="standalone" || $mode=="backend"){ ?>
-var SPEEDTEST_SERVERS=[];
-<?php } else { ?>
-var SPEEDTEST_SERVERS= <?= file_get_contents('/servers.json') ?: '[]' ?>;
-<?php } 
-if ($mode=="dual"){ ?>
-// add own server in dual mode
-SPEEDTEST_SERVERS.unshift({
-	"name":"This Server",
-	"server":document.location.href+"backend/",
-	"id":1,
-	"dlURL":"garbage.php",
-	"ulURL":"empty.php",
-	"pingURL":"empty.php",
-	"getIpURL":"getIP.php",
-	"pingT":-1
-})
-<?php } ?>
-//INITIALIZE SPEED TEST
-var s=new Speedtest(); //create speed test object
-<?php if(getenv("TELEMETRY")=="true"){ ?>
-s.setParameter("telemetry_level","basic");
-<?php } ?>
-<?php if(getenv("DISABLE_IPINFO")=="true"){ ?>
-s.setParameter("getIp_ispInfo",false);
-<?php } ?>
-<?php if(getenv("DISTANCE")){ ?>
-s.setParameter("getIp_ispInfo_distance","<?=getenv("DISTANCE") ?>");
-<?php } ?>
-
-//SERVER AUTO SELECTION
-function initServers(){
-	if(SPEEDTEST_SERVERS.length==0){ //standalone installation
-		//just make the UI visible
-		I("loading").className="hidden";
-		I("serverArea").style.display="none";
-		I("testWrapper").className="visible";
-		initUI();
-	}else{ //multiple servers
-		var noServersAvailable=function(){
-			I("message").innerHTML="No servers available";
-		}
-		var runServerSelect=function(){
-			s.selectServer(function(server){
-				if(server!=null){ //at least 1 server is available
-					I("loading").className="hidden"; //hide loading message
-					//populate server list for manual selection
-					for(var i=0;i<SPEEDTEST_SERVERS.length;i++){
-						if(SPEEDTEST_SERVERS[i].pingT==-1) continue;
-						var option=document.createElement("option");
-						option.value=i;
-						option.textContent=SPEEDTEST_SERVERS[i].name;
-						if(SPEEDTEST_SERVERS[i]===server) option.selected=true;
-						I("server").appendChild(option);
-					}
-					//show test UI
-					I("testWrapper").className="visible";
-					initUI();
-				}else{ //no servers are available, the test cannot proceed
-					noServersAvailable();
-				}
-			});
-		}
-		if(typeof SPEEDTEST_SERVERS === "string"){
-			//need to fetch list of servers from specified URL
-			s.loadServerList(SPEEDTEST_SERVERS,function(servers){
-				if(servers==null){ //failed to load server list
-					noServersAvailable();
-				}else{ //server list loaded
-					SPEEDTEST_SERVERS=servers;
-					runServerSelect();
-				}
-			});
-		}else{
-			//hardcoded server list
-			s.addTestPoints(SPEEDTEST_SERVERS);
-			runServerSelect();
-		}
-	}
-}
-
-var meterBk=/Trident.*rv:(\d+\.\d+)/i.test(navigator.userAgent)?"#EAEAEA":"#80808040";
-var dlColor="#6060AA",
-	ulColor="#616161";
-var progColor=meterBk;
-
-//CODE FOR GAUGES
-function drawMeter(c,amount,bk,fg,progress,prog){
-	var ctx=c.getContext("2d");
-	var dp=window.devicePixelRatio||1;
-	var cw=c.clientWidth*dp, ch=c.clientHeight*dp;
-	var sizScale=ch*0.0055;
-	if(c.width==cw&&c.height==ch){
-		ctx.clearRect(0,0,cw,ch);
-	}else{
-		c.width=cw;
-		c.height=ch;
-	}
-	ctx.beginPath();
-	ctx.strokeStyle=bk;
-	ctx.lineWidth=12*sizScale;
-	ctx.arc(c.width/2,c.height-58*sizScale,c.height/1.8-ctx.lineWidth,-Math.PI*1.1,Math.PI*0.1);
-	ctx.stroke();
-	ctx.beginPath();
-	ctx.strokeStyle=fg;
-	ctx.lineWidth=12*sizScale;
-	ctx.arc(c.width/2,c.height-58*sizScale,c.height/1.8-ctx.lineWidth,-Math.PI*1.1,amount*Math.PI*1.2-Math.PI*1.1);
-	ctx.stroke();
-	if(typeof progress !== "undefined"){
-		ctx.fillStyle=prog;
-		ctx.fillRect(c.width*0.3,c.height-16*sizScale,c.width*0.4*progress,4*sizScale);
-	}
-}
-function mbpsToAmount(s){
-	return 1-(1/(Math.pow(1.3,Math.sqrt(s))));
-}
-function format(d){
-    d=Number(d);
-    if(d<10) return d.toFixed(2);
-    if(d<100) return d.toFixed(1);
-    return d.toFixed(0);
-}
-
-//UI CODE
-var uiData=null;
-function startStop(){
-    if(s.getState()==3){
-		//speed test is running, abort
-		s.abort();
-		data=null;
-		I("startStopBtn").className="";
-		I("server").disabled=false;
-		initUI();
-	}else{
-		//test is not running, begin
-		I("startStopBtn").className="running";
-		I("shareArea").style.display="none";
-		I("server").disabled=true;
-		s.onupdate=function(data){
-            uiData=data;
-		};
-		s.onend=function(aborted){
-            I("startStopBtn").className="";
-            I("server").disabled=false;
-            updateUI(true);
-            if(!aborted){
-                //if testId is present, show sharing panel, otherwise do nothing
-                try{
-                    var testId=uiData.testId;
-                    if(testId!=null){
-                        var shareURL=window.location.href.substring(0,window.location.href.lastIndexOf("/"))+"/results/?id="+testId;
-                        I("resultsImg").src=shareURL;
-                        I("resultsImg").alt="Download: "+uiData.dlStatus+" Mbps, Upload: "+uiData.ulStatus+" Mbps, Ping: "+uiData.pingStatus+" ms, Jitter: "+uiData.jitterStatus+" ms";
-                        I("resultsURL").value=shareURL;
-                        I("testId").innerHTML=testId;
-                        I("shareArea").style.display="";
-                    }
-                }catch(e){}
-            }
-		};
-		s.start();
-	}
-}
-//this function reads the data sent back by the test and updates the UI
-function updateUI(forced){
-	if(!forced&&s.getState()!=3) return;
-	if(uiData==null) return;
-	var status=uiData.testState;
-	I("ip").textContent=uiData.clientIp;
-	I("dlText").textContent=(status==1&&uiData.dlStatus==0)?"...":format(uiData.dlStatus);
-	drawMeter(I("dlMeter"),mbpsToAmount(Number(uiData.dlStatus*(status==1?oscillate():1))),meterBk,dlColor,Number(uiData.dlProgress),progColor);
-	I("ulText").textContent=(status==3&&uiData.ulStatus==0)?"...":format(uiData.ulStatus);
-	drawMeter(I("ulMeter"),mbpsToAmount(Number(uiData.ulStatus*(status==3?oscillate():1))),meterBk,ulColor,Number(uiData.ulProgress),progColor);
-	I("pingText").textContent=format(uiData.pingStatus);
-	I("jitText").textContent=format(uiData.jitterStatus);
-}
-function oscillate(){
-	return 1+0.02*Math.sin(Date.now()/100);
-}
-//update the UI every frame
-window.requestAnimationFrame=window.requestAnimationFrame||window.webkitRequestAnimationFrame||window.mozRequestAnimationFrame||window.msRequestAnimationFrame||(function(callback,element){setTimeout(callback,1000/60);});
-function frame(){
-	requestAnimationFrame(frame);
-	updateUI();
-}
-frame(); //start frame loop
-//function to (re)initialize UI
-function initUI(){
-	drawMeter(I("dlMeter"),0,meterBk,dlColor,0);
-	drawMeter(I("ulMeter"),0,meterBk,ulColor,0);
-	I("dlText").textContent="";
-	I("ulText").textContent="";
-	I("pingText").textContent="";
-	I("jitText").textContent="";
-	I("ip").textContent="";
-}
-</script>
-<style type="text/css">
-	html,body{
-		border:none; padding:0; margin:0;
-		background:#FFFFFF;
-		color:#202020;
-	}
-	body{
-		text-align:center;
-		font-family:"Roboto",sans-serif;
-	}
-	h1{
-		color:#404040;
-	}
-	#loading{
-		background-color:#FFFFFF;
-		color:#404040;
-		text-align:center;
-	}
-	span.loadCircle{
-		display:inline-block;
-		width:2em;
-		height:2em;
-		vertical-align:middle;
-		background:url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAAAP1BMVEUAAAB2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZyFzwnAAAAFHRSTlMAEvRFvX406baecwbf0casimhSHyiwmqgAAADpSURBVHja7dbJbQMxAENRahnN5lkc//5rDRAkDeRgHszXgACJoKiIiIiIiIiIiIiIiIiIiIj4HHspsrpAVhdVVguzrA4OWc10WcEqpwKbnBo0OU1Q5NSpsoJFTgOecrrdEag85DRgktNqfoEdTjnd7hrEHMEJvmRUYJbTYk5Agy6nau6Abp5Cm7mDBtRdPi9gyKdU7w4p1fsLvyqs8hl4z9/w3n/Hmr9WoQ65lAU4d7lMYOz//QboRR5jBZibLMZdAR6O/Vfa1PlxNr3XdS3HzK/HVPRu/KnLs8iAOh993VpRRERERMT/fAN60wwWaVyWwAAAAABJRU5ErkJggg==');
-		background-size:2em 2em;
-		margin-right:0.5em;
-		animation: spin 0.6s linear infinite;
-	}
-	@keyframes spin{
-		0%{transform:rotate(0deg);}
-		100%{transform:rotate(359deg);}
-	}
-	#startStopBtn{
-		display:inline-block;
-		margin:0 auto;
-		color:#6060AA;
-		background-color:rgba(0,0,0,0);
-		border:0.15em solid #6060FF;
-		border-radius:0.3em;
-		transition:all 0.3s;
-		box-sizing:border-box;
-		width:8em; height:3em;
-		line-height:2.7em;
-		cursor:pointer;
-		box-shadow: 0 0 0 rgba(0,0,0,0.1), inset 0 0 0 rgba(0,0,0,0.1);
-	}
-	#startStopBtn:hover{
-		box-shadow: 0 0 2em rgba(0,0,0,0.1), inset 0 0 1em rgba(0,0,0,0.1);
-	}
-	#startStopBtn.running{
-		background-color:#FF3030;
-		border-color:#FF6060;
-		color:#FFFFFF;
-	}
-	#startStopBtn:before{
-		content:"Start";
-	}
-	#startStopBtn.running:before{
-		content:"Abort";
-	}
-	#serverArea{
-		margin-top:1em;
-	}
-	#server{
-		font-size:1em;
-		padding:0.2em;
-	}
-	#test{
-		margin-top:2em;
-		margin-bottom:12em;
-	}
-	div.testArea{
-		display:inline-block;
-		width:16em;
-		height:12.5em;
-		position:relative;
-		box-sizing:border-box;
-	}
-	div.testArea2{
-		display:inline-block;
-		width:14em;
-		height:7em;
-		position:relative;
-		box-sizing:border-box;
-		text-align:center;
-	}
-	div.testArea div.testName{
-		position:absolute;
-		top:0.1em; left:0;
-		width:100%;
-		font-size:1.4em;
-		z-index:9;
-	}
-	div.testArea2 div.testName{
-        display:block;
-        text-align:center;
-        font-size:1.4em;
-	}
-	div.testArea div.meterText{
-		position:absolute;
-		bottom:1.55em; left:0;
-		width:100%;
-		font-size:2.5em;
-		z-index:9;
-	}
-	div.testArea2 div.meterText{
-        display:inline-block;
-        font-size:2.5em;
-	}
-	div.meterText:empty:before{
-		content:"0.00";
-	}
-	div.testArea div.unit{
-		position:absolute;
-		bottom:2em; left:0;
-		width:100%;
-		z-index:9;
-	}
-	div.testArea2 div.unit{
-		display:inline-block;
-	}
-	div.testArea canvas{
-		position:absolute;
-		top:0; left:0; width:100%; height:100%;
-		z-index:1;
-	}
-	div.testGroup{
-		display:block;
-        margin: 0 auto;
-	}
-	#shareArea{
-		width:95%;
-		max-width:40em;
-		margin:0 auto;
-		margin-top:2em;
-	}
-	#shareArea > *{
-		display:block;
-		width:100%;
-		height:auto;
-		margin: 0.25em 0;
-	}
-	#privacyPolicy{
-        position:fixed;
-        top:2em;
-        bottom:2em;
-        left:2em;
-        right:2em;
-        overflow-y:auto;
-        width:auto;
-        height:auto;
-        box-shadow:0 0 3em 1em #000000;
-        z-index:999999;
-        text-align:left;
-        background-color:#FFFFFF;
-        padding:1em;
-	}
-	a.privacy{
-        text-align:center;
-        font-size:0.8em;
-        color:#808080;
-        padding: 0 3em;
-	}
-    div.closePrivacyPolicy {
-        width: 100%;
-        text-align: center;
-    }
-    div.closePrivacyPolicy a.privacy {
-        padding: 1em 3em;
-    }
-	@media all and (max-width:40em){
-		body{
-			font-size:0.8em;
-		}
-	}
-	div.visible{
-		animation: fadeIn 0.4s;
-		display:block;
-	}
-	div.hidden{
-		animation: fadeOut 0.4s;
-		display:none;
-	}
-	@keyframes fadeIn{
-		0%{
-			opacity:0;
-		}
-		100%{
-			opacity:1;
-		}
-	}
-	@keyframes fadeOut{
-		0%{
-			display:block;
-			opacity:1;
-		}
-		100%{
-			display:block;
-			opacity:0;
-		}
-	}
-	@media all and (prefers-color-scheme: dark){
-		html,body,#loading{
-			background:#202020;
-			color:#F4F4F4;
-			color-scheme:dark;
-		}
-		h1{
-			color:#E0E0E0;
-		}
-		a{
-			color:#9090FF;
-		}
-		#privacyPolicy{
-			background:#000000;
-		}
-		#resultsImg{
-			filter: invert(1);
-		}
-	}
-</style>
-<title><?= getenv('TITLE') ?: 'LibreSpeed' ?></title>
-</head>
-<body onload="initServers()">
-<h1><?= getenv('TITLE') ?: 'LibreSpeed' ?></h1>
-<div id="loading" class="visible">
-	<p id="message"><span class="loadCircle"></span>Selecting a server...</p>
-</div>
-<div id="testWrapper" class="hidden">
-	<div id="startStopBtn" onclick="startStop()"></div><br/>
-	<?php if(getenv("TELEMETRY")=="true"){ ?>
-		<a class="privacy" href="#" onclick="I('privacyPolicy').style.display=''">Privacy</a>
-	<?php } ?>
-	<div id="serverArea">
-		Server: <select id="server" onchange="s.setSelectedServer(SPEEDTEST_SERVERS[this.value])"></select>
-	</div>
-	<div id="test">
-		<div class="testGroup">
-            <div class="testArea2">
-				<div class="testName">Ping</div>
-				<div id="pingText" class="meterText" style="color:#AA6060"></div>
-				<div class="unit">ms</div>
-			</div>
-			<div class="testArea2">
-				<div class="testName">Jitter</div>
-				<div id="jitText" class="meterText" style="color:#AA6060"></div>
-				<div class="unit">ms</div>
-			</div>
-		</div>
-		<div class="testGroup">
-			<div class="testArea">
-				<div class="testName">Download</div>
-				<canvas id="dlMeter" class="meter"></canvas>
-				<div id="dlText" class="meterText"></div>
-				<div class="unit">Mbit/s</div>
-			</div>
-			<div class="testArea">
-				<div class="testName">Upload</div>
-				<canvas id="ulMeter" class="meter"></canvas>
-				<div id="ulText" class="meterText"></div>
-				<div class="unit">Mbit/s</div>
-			</div>
-		</div>
-		<div id="ipArea">
-			<span id="ip"></span>
-		</div>
-		<div id="shareArea" style="display:none">
-			<h3>Share results</h3>
-			<p>Test ID: <span id="testId"></span></p>
-			<input type="text" value="" id="resultsURL" readonly="readonly" onclick="this.select();this.focus();this.select();document.execCommand('copy');alert('Link copied')"/>
-			<img src="" id="resultsImg" />
-		</div>
-	</div>
-	<a href="stability.html">Stability Test</a> | <a href="https://github.com/librespeed/speedtest">Source code</a>
-</div>
-<div id="privacyPolicy" style="display:none">
-    <h2>Privacy Policy</h2>
-    <p>This HTML5 speed test server is configured with telemetry enabled.</p>
-    <h4>What data we collect</h4>
-    <p>
-        At the end of the test, the following data is collected and stored:
-        <ul>
-            <li>Test ID</li>
-            <li>Time of testing</li>
-            <li>Test results (download and upload speed, ping and jitter)</li>
-            <li>IP address</li>
-            <li>ISP information</li>
-            <li>Approximate location (inferred from IP address, not GPS)</li>
-            <li>User agent and browser locale</li>
-            <li>Test log (contains no personal information)</li>
-        </ul>
-    </p>
-    <h4>How we use the data</h4>
-    <p>
-        Data collected through this service is used to:
-        <ul>
-            <li>Allow sharing of test results (sharable image for forums, etc.)</li>
-            <li>To improve the service offered to you (for instance, to detect problems on our side)</li>
-        </ul>
-        No personal information is disclosed to third parties.
-    </p>
-    <h4>Your consent</h4>
-    <p>
-        By starting the test, you consent to the terms of this privacy policy.
-    </p>
-    <h4>Data removal</h4>
-    <p>
-        If you want to have your information deleted, you need to provide either the ID of the test or your IP address. This is the only way to identify your data, without this information we won't be able to comply with your request.<br/><br/>
-        Contact this email address for all deletion requests: <a href="mailto:<?=getenv("EMAIL") ?>"><?=getenv("EMAIL") ?></a>.
-    </p>
-    <br/><br/>
-    <div class="closePrivacyPolicy">
-        <a class="privacy" href="#" onclick="I('privacyPolicy').style.display='none'">Close</a>
-    </div>
-    <br/>
-</div>
-</body>
-</html>

+ 6 - 1
examples/example-multipleServers-full.html

@@ -126,11 +126,13 @@ function startStop(){
 		s.abort();
 		data=null;
 		I("startStopBtn").className="";
+		I("startStopBtn").setAttribute("aria-label","Start");
 		I("server").disabled=false;
 		initUI();
 	}else{
 		//test is not running, begin
 		I("startStopBtn").className="running";
+		I("startStopBtn").setAttribute("aria-label","Abort");
 		I("shareArea").style.display="none";
 		I("server").disabled=true;
 		s.onupdate=function(data){
@@ -138,6 +140,7 @@ function startStop(){
 		};
 		s.onend=function(aborted){
             I("startStopBtn").className="";
+            I("startStopBtn").setAttribute("aria-label","Start");
             I("server").disabled=false;
             updateUI(true);
             if(!aborted){
@@ -230,6 +233,8 @@ function initUI(){
 		color:#6060AA;
 		background-color:rgba(0,0,0,0);
 		border:0.15em solid #6060FF;
+		padding:0;
+		font:inherit;
 		border-radius:0.3em;
 		transition:all 0.3s;
 		box-sizing:border-box;
@@ -402,7 +407,7 @@ function initUI(){
 	<p id="message"><span class="loadCircle"></span>Selecting a server...</p>
 </div>
 <div id="testWrapper" class="hidden">
-	<div id="startStopBtn" onclick="startStop()"></div><br/>
+	<button id="startStopBtn" onclick="startStop()" aria-label="Start"></button><br/>
 	<a class="privacy" href="#" onclick="I('privacyPolicy').style.display=''">Privacy</a>
 	<div id="serverArea">
 		Server: <select id="server" onchange="s.setSelectedServer(SPEEDTEST_SERVERS[this.value])"></select>

+ 5 - 1
examples/example-multipleServers-pretty.html

@@ -42,6 +42,7 @@ s.onupdate=function(data){ //callback to update data in UI
 }
 s.onend=function(aborted){ //callback for test ended/aborted
     I("startStopBtn").className=""; //show start button again
+    I("startStopBtn").setAttribute("aria-label","Start");
     if(aborted){ //if the test was aborted, clear the UI and prepare for new test
 		initUI();
     }
@@ -83,6 +84,7 @@ function startStop(){ //start/stop button pressed
 		//test is not running, begin
 		s.start();
 		I("startStopBtn").className="running";
+		I("startStopBtn").setAttribute("aria-label","Abort");
 	}
 }
 
@@ -117,6 +119,8 @@ function I(id){return document.getElementById(id);}
 		color:#6060AA;
 		background-color:rgba(0,0,0,0);
 		border:0.15em solid #6060FF;
+		padding:0;
+		font:inherit;
 		border-radius:0.3em;
 		transition:all 0.3s;
 		box-sizing:border-box;
@@ -205,7 +209,7 @@ function I(id){return document.getElementById(id);}
 </head>
 <body>
 <h1>LibreSpeed Example</h1>
-<div id="startStopBtn" onclick="startStop()"></div>
+<button id="startStopBtn" onclick="startStop()" aria-label="Start"></button>
 <div id="serverId">Selecting server...</div>
 <div id="test">
 	<div class="testGroup">

+ 5 - 1
examples/example-singleServer-customSettings.html

@@ -22,6 +22,7 @@ s.onupdate=function(data){ //callback to update data in UI
 }
 s.onend=function(aborted){ //callback for test ended/aborted
     I("startStopBtn").className=""; //show start button again
+    I("startStopBtn").setAttribute("aria-label","Start");
     if(aborted){ //if the test was aborted, clear the UI and prepare for new test
 		initUI();
     }
@@ -35,6 +36,7 @@ function startStop(){ //start/stop button pressed
 		//test is not running, begin
 		s.start();
 		I("startStopBtn").className="running";
+		I("startStopBtn").setAttribute("aria-label","Abort");
 	}
 }
 
@@ -66,6 +68,8 @@ function I(id){return document.getElementById(id);}
 		color:#6060AA;
 		background-color:rgba(0,0,0,0);
 		border:0.15em solid #6060FF;
+		padding:0;
+		font:inherit;
 		border-radius:0.3em;
 		transition:all 0.3s;
 		box-sizing:border-box;
@@ -151,7 +155,7 @@ function I(id){return document.getElementById(id);}
 </head>
 <body>
 <h1>LibreSpeed Example</h1>
-<div id="startStopBtn" onclick="startStop()"></div>
+<button id="startStopBtn" onclick="startStop()" aria-label="Start"></button>
 <div id="test">
 	<div class="testGroup">
 		<div class="testArea">

+ 6 - 1
examples/example-singleServer-gauges.html

@@ -60,15 +60,18 @@ function startStop(){
 		s.abort();
 		data=null;
 		I("startStopBtn").className="";
+		I("startStopBtn").setAttribute("aria-label","Start");
 		initUI();
 	}else{
 		//test is not running, begin
 		I("startStopBtn").className="running";
+		I("startStopBtn").setAttribute("aria-label","Abort");
 		s.onupdate=function(data){
             uiData=data;
 		};
 		s.onend=function(aborted){
             I("startStopBtn").className="";
+            I("startStopBtn").setAttribute("aria-label","Start");
             updateUI(true);
 		};
 		s.start();
@@ -127,6 +130,8 @@ function initUI(){
 		color:#6060AA;
 		background-color:rgba(0,0,0,0);
 		border:0.15em solid #6060FF;
+		padding:0;
+		font:inherit;
 		border-radius:0.3em;
 		transition:all 0.3s;
 		box-sizing:border-box;
@@ -223,7 +228,7 @@ function initUI(){
 <body>
 <h1>LibreSpeed Example</h1>
 <div id="testWrapper">
-	<div id="startStopBtn" onclick="startStop()"></div>
+	<button id="startStopBtn" onclick="startStop()" aria-label="Start"></button>
 	<div id="test">
         <div class="testGroup">
 			<div class="testArea2">

+ 5 - 1
examples/example-singleServer-modern.html

@@ -23,6 +23,7 @@ s.onupdate=function(data){ //callback to update data in UI
 }
 s.onend=function(aborted){ //callback for test ended/aborted
     I("startStopBtn").className=""; //show start button again
+    I("startStopBtn").setAttribute("aria-label","Start");
     if(aborted){ //if the test was aborted, clear the UI and prepare for new test
         initUI();
     } else {
@@ -44,6 +45,7 @@ function startStop(){ //start/stop button pressed
         //test is not running, begin
         s.start();
         I("startStopBtn").className="running";
+        I("startStopBtn").setAttribute("aria-label","Abort");
     }
 }
 
@@ -84,6 +86,8 @@ window.onload = function() {
         color:#fff;
         background-color:#007bff;
         border:none;
+        padding:0;
+        font:inherit;
         border-radius:0.3em;
         transition:all 0.3s;
         box-sizing:border-box;
@@ -188,7 +192,7 @@ window.onload = function() {
 </head>
 <body>
 <h1>LibreSpeed</h1>
-<div id="startStopBtn" onclick="startStop()"></div>
+<button id="startStopBtn" onclick="startStop()" aria-label="Start"></button>
 <div id="test">
     <div class="testGroup">
         <div class="testArea">

+ 5 - 1
examples/example-singleServer-pretty.html

@@ -19,6 +19,7 @@ s.onupdate=function(data){ //callback to update data in UI
 }
 s.onend=function(aborted){ //callback for test ended/aborted
     I("startStopBtn").className=""; //show start button again
+    I("startStopBtn").setAttribute("aria-label","Start");
     if(aborted){ //if the test was aborted, clear the UI and prepare for new test
 		initUI();
     }
@@ -32,6 +33,7 @@ function startStop(){ //start/stop button pressed
 		//test is not running, begin
 		s.start();
 		I("startStopBtn").className="running";
+		I("startStopBtn").setAttribute("aria-label","Abort");
 	}
 }
 
@@ -66,6 +68,8 @@ function I(id){return document.getElementById(id);}
 		color:#6060AA;
 		background-color:rgba(0,0,0,0);
 		border:0.15em solid #6060FF;
+		padding:0;
+		font:inherit;
 		border-radius:0.3em;
 		transition:all 0.3s;
 		box-sizing:border-box;
@@ -154,7 +158,7 @@ function I(id){return document.getElementById(id);}
 </head>
 <body>
 <h1>LibreSpeed Example</h1>
-<div id="startStopBtn" onclick="startStop()"></div>
+<button id="startStopBtn" onclick="startStop()" aria-label="Start"></button>
 <div id="test">
 	<div class="testGroup">
 		<div class="testArea">

+ 5 - 1
examples/example-singleServer-progressBar.html

@@ -21,6 +21,7 @@ s.onupdate=function(data){ //callback to update data in UI
 }
 s.onend=function(aborted){ //callback for test ended/aborted
     I("startStopBtn").className=""; //show start button again
+    I("startStopBtn").setAttribute("aria-label","Start");
     if(aborted){ //if the test was aborted, clear the UI and prepare for new test
 		initUI();
     }
@@ -34,6 +35,7 @@ function startStop(){ //start/stop button pressed
 		//test is not running, begin
 		s.start();
 		I("startStopBtn").className="running";
+		I("startStopBtn").setAttribute("aria-label","Abort");
 	}
 }
 
@@ -68,6 +70,8 @@ function I(id){return document.getElementById(id);}
 		color:#6060AA;
 		background-color:rgba(0,0,0,0);
 		border:0.15em solid #6060FF;
+		padding:0;
+		font:inherit;
 		border-radius:0.3em;
 		transition:all 0.3s;
 		box-sizing:border-box;
@@ -173,7 +177,7 @@ function I(id){return document.getElementById(id);}
 </head>
 <body>
 <h1>LibreSpeed Example</h1>
-<div id="startStopBtn" onclick="startStop()"></div>
+<button id="startStopBtn" onclick="startStop()" aria-label="Start"></button>
 <div id="test">
     <div id="progressBar"><div id="progress"></div></div>
 	<div class="testGroup">

+ 22 - 0
frontend/README.md

@@ -0,0 +1,22 @@
+# LibreSpeed frontend
+
+This directory contains the modern LibreSpeed UI assets.
+
+## Deployment
+
+For installation and deployment, follow the top-level [README.md](../README.md)
+and [DESIGN_SWITCH.md](../DESIGN_SWITCH.md). In non-Docker deployments, the
+contents of this directory must be copied so `styling/`, `javascript/`,
+`images/`, and `fonts/` sit next to the root HTML files.
+
+## Configuration
+
+- `server-list.json` contains the default server list used by the modern UI.
+- `settings.json` overrides selected `speedtest_worker.js` settings.
+- `index.html` and `index-modern.html` show how the frontend is wired up.
+
+## Notes
+
+- The modern frontend expects modern browser features and does not support old
+  browsers.
+- This directory does not contain the backend or results-sharing files.

BIN
frontend/fonts/Inter-latin-ext.woff2


BIN
frontend/fonts/Inter-latin.woff2


BIN
frontend/images/background-original.jpeg


BIN
frontend/images/background.jpeg


+ 22 - 0
frontend/images/chevron.svg

@@ -0,0 +1,22 @@
+<svg
+    width="32"
+    height="32"
+    viewBox="0 0 32 32"
+    fill="none"
+    xmlns="http://www.w3.org/2000/svg"
+>
+    <g clip-path="url(#clip0_373_537)">
+        <path
+            d="M26 12L16 22L6 12"
+            stroke="#625B6B"
+            stroke-width="1.5"
+            stroke-linecap="round"
+            stroke-linejoin="round"
+        />
+    </g>
+    <defs>
+        <clipPath id="clip0_373_537">
+            <rect width="32" height="32" fill="white" />
+        </clipPath>
+    </defs>
+</svg>

+ 14 - 0
frontend/images/close-button.svg

@@ -0,0 +1,14 @@
+<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <g clip-path="url(#clip0_373_731)">
+        <path d="M25 7L7 25" stroke="#F5F5F5" stroke-width="2" stroke-linecap="round"
+            stroke-linejoin="round" />
+        <path d="M25 25L7 7" stroke="#F5F5F5" stroke-width="2" stroke-linecap="round"
+            stroke-linejoin="round" />
+    </g>
+    <defs>
+        <clipPath id="clip0_373_731">
+            <rect width="32" height="32" fill="white" />
+        </clipPath>
+    </defs>
+</svg>
+    

+ 19 - 0
frontend/images/favicon.svg

@@ -0,0 +1,19 @@
+<svg width="19" height="18" viewBox="0 0 19 18" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <path
+        d="M16.0345 0.123047H3.20289C1.88703 0.123047 0.820312 1.18976 0.820312 2.50562V15.3373C0.820312 16.6531 1.88703 17.7198 3.20289 17.7198H16.0345C17.3504 17.7198 18.4171 16.6531 18.4171 15.3373V2.50562C18.4171 1.18976 17.3504 0.123047 16.0345 0.123047Z"
+        fill="#F5F5F5" />
+    <path
+        d="M17.1947 2.38135H3.55467L3.42638 9.32426L8.84467 9.49601L8.25888 9.99686L2.04297 15.4616L16.3914 15.3137V8.62233L10.1332 7.52898L17.1947 2.38135Z"
+        fill="url(#paint0_linear_373_542)" />
+    <defs>
+        <linearGradient id="paint0_linear_373_542" x1="2.04297" y1="8.92149" x2="17.1947"
+            y2="8.92149" gradientUnits="userSpaceOnUse">
+            <stop stop-color="#D63BC6" />
+            <stop offset="0.3478" stop-color="#7419B1" />
+            <stop offset="0.5389" stop-color="#3D06A5" />
+            <stop offset="0.7532" stop-color="#485DC4" />
+            <stop offset="1" stop-color="#5CF9FD" />
+        </linearGradient>
+    </defs>
+</svg>
+    

+ 66 - 0
frontend/images/logo.svg

@@ -0,0 +1,66 @@
+<svg
+    width="153"
+    height="18"
+    viewBox="0 0 153 18"
+    fill="none"
+    xmlns="http://www.w3.org/2000/svg"
+    class="logo"
+>
+    <path
+        d="M87.0345 0.123047H74.2029C72.887 0.123047 71.8203 1.18976 71.8203 2.50562V15.3373C71.8203 16.6531 72.887 17.7198 74.2029 17.7198H87.0345C88.3504 17.7198 89.4171 16.6531 89.4171 15.3373V2.50562C89.4171 1.18976 88.3504 0.123047 87.0345 0.123047Z"
+        fill="#F5F5F5"
+    />
+    <path
+        d="M88.1947 2.38135H74.5547L74.4264 9.32426L79.8447 9.49601L79.2589 9.99686L73.043 15.4616L87.3914 15.3137V8.62233L81.1332 7.52898L88.1947 2.38135Z"
+        fill="url(#paint0_linear_373_540)"
+    />
+    <path
+        d="M0 17.4134V0H3.68171V14.378H11.1473V17.4134H0Z"
+        fill="#F5F5F5"
+    />
+    <path d="M17.2686 0V17.4134H13.5869V0H17.2686Z" fill="#F5F5F5" />
+    <path
+        d="M20.3038 17.4134V0H27.2761C28.5572 0 29.6258 0.189989 30.4818 0.569601C31.3374 0.94958 31.9808 1.47506 32.4117 2.14682C32.8425 2.81857 33.058 3.59104 33.058 4.46387C33.058 5.14385 32.9218 5.74068 32.6497 6.25365C32.3776 6.76661 32.0048 7.18598 31.5317 7.51218C31.0582 7.83802 30.5183 8.06886 29.9118 8.20508V8.37499C30.5749 8.40366 31.1972 8.59042 31.7783 8.93633C32.3593 9.28225 32.8311 9.76544 33.1938 10.3859C33.5566 11.0068 33.7379 11.7452 33.7379 12.6008C33.7379 13.5249 33.51 14.3483 33.0536 15.0709C32.5973 15.7936 31.9242 16.365 31.0341 16.7844C30.144 17.2038 29.0475 17.4135 27.7438 17.4135L20.3038 17.4134ZM23.9855 7.28666H26.7148C27.2195 7.28666 27.6686 7.19739 28.0626 7.01889C28.4565 6.84039 28.7684 6.58661 28.9978 6.25791C29.2276 5.92921 29.3423 5.53524 29.3423 5.07609C29.3423 4.44666 29.1197 3.93942 28.6748 3.55408C28.2296 3.16874 27.5991 2.97589 26.7829 2.97589H23.9855V7.28666ZM23.9855 14.4034H26.9869C28.0127 14.4034 28.7612 14.2067 29.2319 13.8123C29.7021 13.4184 29.9373 12.8929 29.9373 12.2351C29.9373 11.7534 29.8212 11.3282 29.5889 10.9597C29.3563 10.5913 29.0261 10.3023 28.5981 10.0926C28.1701 9.88294 27.6615 9.77792 27.0722 9.77792H23.9855L23.9855 14.4034Z"
+        fill="#F5F5F5"
+    />
+    <path
+        d="M36.127 17.4134V0H42.9971C44.3123 0 45.436 0.233706 46.3684 0.701485C47.3011 1.1689 48.0123 1.82953 48.5027 2.68232C48.993 3.5358 49.2382 4.53769 49.2382 5.68834C49.2382 6.84472 48.9887 7.83798 48.4901 8.66853C47.9911 9.4987 47.2699 10.135 46.3261 10.5773C45.3823 11.0193 44.2413 11.2404 42.9036 11.2404H38.3039V8.28139H42.3086C43.0115 8.28139 43.5955 8.18533 44.06 7.99248C44.5249 7.79963 44.8723 7.51071 45.1017 7.12501C45.3315 6.73967 45.4462 6.26077 45.4462 5.68831C45.4462 5.11011 45.3315 4.62262 45.1017 4.2258C44.8723 3.82897 44.5235 3.52717 44.0557 3.31997C43.5883 3.11313 43.0001 3.00992 42.2917 3.00992H39.8087V17.4134L36.127 17.4134ZM45.531 9.48901L49.859 17.4134H45.7945L41.5604 9.48901H45.531Z"
+        fill="#F5F5F5"
+    />
+    <path
+        d="M51.8398 17.4134V0H63.5735V3.03539H55.5216V7.18451H62.9699V10.2203H55.5216V14.378H63.6076V17.4134L51.8398 17.4134Z"
+        fill="#F5F5F5"
+    />
+    <path
+        d="M92.8662 17.4134V0H99.7364C101.058 0 102.182 0.250554 103.112 0.752396C104.042 1.25387 104.752 1.94857 105.242 2.83538C105.733 3.72256 105.978 4.74452 105.978 5.90091C105.978 7.05729 105.728 8.07745 105.229 8.96177C104.73 9.84609 104.009 10.5347 103.066 11.0279C102.122 11.5212 100.981 11.7677 99.6432 11.7677H95.2643V8.8173H99.0481C99.7564 8.8173 100.341 8.694 100.804 8.44737C101.266 8.20075 101.612 7.85773 101.841 7.41862C102.071 6.97914 102.185 6.47337 102.185 5.90091C102.185 5.32272 102.071 4.81658 101.841 4.38319C101.612 3.94948 101.264 3.61215 100.8 3.37129C100.335 3.13042 99.745 3.00995 99.0309 3.00995H96.5482V17.4134H92.8662Z"
+        fill="#F5F5F5"
+    />
+    <path
+        d="M108.375 17.4134V0H120.109V3.03539H112.057V7.18451H119.505V10.2203H112.057V14.378H120.143V17.4134L108.375 17.4134Z"
+        fill="#F5F5F5"
+    />
+    <path
+        d="M123.042 17.4134V0H134.776V3.03539H126.723V7.18451H134.172V10.2203H126.723V14.378H134.809V17.4134L123.042 17.4134Z"
+        fill="#F5F5F5"
+    />
+    <path
+        d="M143.882 17.4134H137.709V0H143.933C145.685 0 147.192 0.34698 148.457 1.04131C149.721 1.736 150.694 2.73213 151.378 4.03012C152.06 5.32844 152.402 6.88165 152.402 8.68967C152.402 10.5035 152.06 12.0624 151.378 13.3661C150.694 14.6698 149.716 15.6702 148.444 16.3674C147.171 17.0647 145.651 17.4134 143.882 17.4134ZM141.391 14.259H143.729C144.817 14.259 145.734 14.0647 146.48 13.6765C147.225 13.2883 147.787 12.6847 148.163 11.8652C148.54 11.0465 148.729 9.98763 148.729 8.68963C148.729 7.40276 148.54 6.35143 148.163 5.53521C147.787 4.71865 147.227 4.11788 146.484 3.73254C145.741 3.34721 144.826 3.15435 143.737 3.15435H141.391L141.391 14.259Z"
+        fill="#F5F5F5"
+    />
+    <defs>
+        <linearGradient
+            id="paint0_linear_373_540"
+            x1="73.043"
+            y1="8.92149"
+            x2="88.1947"
+            y2="8.92149"
+            gradientUnits="userSpaceOnUse"
+        >
+            <stop stop-color="#D63BC6" />
+            <stop offset="0.3478" stop-color="#7419B1" />
+            <stop offset="0.5389" stop-color="#3D06A5" />
+            <stop offset="0.7532" stop-color="#485DC4" />
+            <stop offset="1" stop-color="#5CF9FD" />
+        </linearGradient>
+    </defs>
+</svg>

+ 151 - 0
frontend/index.html

@@ -0,0 +1,151 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta
+      name="viewport"
+      content="width=device-width, initial-scale=1, shrink-to-fit=no"
+    />
+    <meta
+      name="description"
+      content="Free and Open Source Speedtest. Run it right now in your browser, or self-host on a PHP, Golang, Rust or Node server. License: LGPL."
+    />
+    <link rel="shortcut icon" href="images/favicon.svg" />
+    <script type="text/javascript" src="speedtest.js"></script>
+    <script type="text/javascript" src="javascript/index.js"></script>
+    <link rel="stylesheet" type="text/css" href="styling/index.css" />
+    <title>LibreSpeed - Free and Open Source Speedtest</title>
+  </head>
+
+  <body>
+    <header>
+      <img src="images/logo.svg" alt="LibreSpeed" />
+    </header>
+    <main>
+      <h1>Free and Open Source Speedtest.</h1>
+      <p class="tagline">No Flash, No Java, No Websockets, No Bullsh*t</p>
+
+      <div class="server-selector">
+        <div class="chosen">
+          <div class="chevron">
+            <img src="images/chevron.svg" alt="select..." />
+          </div>
+          <p>current server</p>
+          <h2 id="selected-server">searching nearest server...</h2>
+        </div>
+        <ul class="servers"></ul>
+        <p class="sponsor" id="sponsor">&nbsp;</p>
+      </div>
+
+      <p id="privacy-warning" class="hidden">
+        by clicking the start button you agree to our privacy policy<br />
+        <a href="#" id="choose-privacy">or choose your privacy options</a>
+      </p>
+      <button class="disabled" id="start-button"></button>
+
+      <div class="gauge-layout">
+        <div class="ping hidden">
+          <span class="label">Ping</span>:&nbsp;
+          <span class="value" id="ping">00</span>ms
+        </div>
+
+        <div class="gauge download" id="download-gauge">
+          <div class="progress"></div>
+          <div class="speed"></div>
+          <h1><span id="download-speed">00</span> Mbps</h1>
+          <h2>Download</h2>
+        </div>
+
+        <div class="gauge upload" id="upload-gauge">
+          <div class="progress"></div>
+          <div class="speed"></div>
+          <h1><span id="upload-speed">00</span> Mbps</h1>
+          <h2>Upload</h2>
+        </div>
+
+        <div class="jitter hidden">
+          <span class="label">Jitter</span>:&nbsp;
+          <span class="value" id="jitter">00</span>ms
+        </div>
+      </div>
+
+      <button class="small inverted hidden" id="share-results">
+        Share results
+      </button>
+    </main>
+    <footer>
+      <p class="source">
+        <a href="https://github.com/librespeed/speedtest">source code</a>
+      </p>
+    </footer>
+
+    <dialog id="share">
+      <div class="close-dialog">
+        <img src="images/close-button.svg" alt="Close" />
+      </div>
+      <img id="results" src="" alt="Test results in graphical form" />
+      <button id="copy-link">Copy link</button>
+    </dialog>
+
+    <dialog id="privacy">
+      <div class="close-dialog">
+        <img src="images/close-button.svg" alt="Close" />
+      </div>
+      <section>
+        <h1>Privacy Policy</h1>
+        <p>
+          This HTML5 speed test server is configured with telemetry enabled.
+        </p>
+
+        <h2>What data we collect</h2>
+        <p>
+          At the end of the test, the following data is collected and stored:
+        </p>
+
+        <ul>
+          <li>Test ID</li>
+          <li>Time of testing</li>
+          <li>Test results (download and upload speed, ping and jitter)</li>
+          <li>IP address</li>
+          <li>ISP information</li>
+          <li>Approximate location (inferred from IP address, not GPS)</li>
+          <li>User agent and browser locale</li>
+          <li>Test log (contains no personal information)</li>
+        </ul>
+
+        <h2>How we use the data</h2>
+        <p>Data collected through this service is used to:</p>
+
+        <ul>
+          <li>
+            Allow sharing of test results (sharable image for forums, etc.)
+          </li>
+          <li>
+            To improve the service offered to you (for instance, to detect
+            problems on our side)
+          </li>
+        </ul>
+
+        <p>No personal information is disclosed to third parties.</p>
+
+        <h2>Your consent</h2>
+        <p>
+          By starting the test, you consent to the terms of this privacy policy.
+        </p>
+
+        <h2>Data removal</h2>
+        <p>
+          If you want to have your information deleted, you need to provide
+          either the ID of the test or your IP address. This is the only way to
+          identify your data, without this information we won't be able to
+          comply with your request.
+        </p>
+        <p>
+          Contact this email address for all deletion requests:
+          <a href="mailto:PUT@YOUR_EMAIL.HERE">TO BE FILLED BY DEVELOPER</a>.
+        </p>
+      </section>
+      <button id="close-privacy">Close</button>
+    </dialog>
+  </body>
+</html>

+ 447 - 0
frontend/javascript/index.js

@@ -0,0 +1,447 @@
+/**
+ * Design by fromScratch Studio - 2022, 2023 (fromscratch.io)
+ * Implementation in HTML/CSS/JS by Timendus - 2024 (https://github.com/Timendus)
+ *
+ * See https://github.com/librespeed/speedtest/issues/585
+ */
+
+// States the UI can be in
+const INITIALIZING = 0;
+const READY = 1;
+const RUNNING = 2;
+const FINISHED = 3;
+
+// Keep some global state here
+const testState = {
+  state: INITIALIZING,
+  speedtest: null,
+  servers: [],
+  selectedServerDirty: false,
+  testData: null,
+  testDataDirty: false,
+  telemetryEnabled: false,
+};
+
+// Bootstrap the application when the DOM is ready
+window.addEventListener("DOMContentLoaded", async () => {
+  createSpeedtest();
+  hookUpButtons();
+  startRenderingLoop();
+  applySettingsJSON();
+  applyServerListJSON();
+});
+
+/**
+ * Create a new Speedtest and hook it into the global state
+ */
+function createSpeedtest() {
+  testState.speedtest = new Speedtest();
+  testState.speedtest.onupdate = (data) => {
+    testState.testData = data;
+    testState.testDataDirty = true;
+  };
+  testState.speedtest.onend = (aborted) =>
+    (testState.state = aborted ? READY : FINISHED);
+}
+
+/**
+ * Make all the buttons respond to the right clicks
+ */
+function hookUpButtons() {
+  document
+    .querySelector("#start-button")
+    .addEventListener("click", startButtonClickHandler);
+  document
+    .querySelector("#choose-privacy")
+    .addEventListener("click", () =>
+      document.querySelector("#privacy").showModal()
+    );
+  document
+    .querySelector("#share-results")
+    .addEventListener("click", () =>
+      document.querySelector("#share").showModal()
+    );
+  document
+    .querySelector("#copy-link")
+    .addEventListener("click", copyLinkButtonClickHandler);
+  document
+    .querySelectorAll(".close-dialog, #close-privacy")
+    .forEach((element) => {
+      element.addEventListener("click", () =>
+        document.querySelectorAll("dialog").forEach((modal) => modal.close())
+      );
+    });
+}
+
+/**
+ * Event listener for clicks on the main start button
+ */
+function startButtonClickHandler() {
+  switch (testState.state) {
+    case READY:
+    case FINISHED:
+      testState.speedtest.start();
+      testState.state = RUNNING;
+      return;
+    case RUNNING:
+      testState.speedtest.abort();
+      // testState.state is updated by `onend` handler of speedtest
+      return;
+    default:
+      return;
+  }
+}
+
+/**
+ * Event listener for clicks on the "Copy link" button in the modal
+ */
+async function copyLinkButtonClickHandler() {
+  const link = document.querySelector("img#results").src;
+  await navigator.clipboard.writeText(link);
+  const button = document.querySelector("#copy-link");
+  button.classList.add("active");
+  button.textContent = "Copied!";
+  setTimeout(() => {
+    button.classList.remove("active");
+    button.textContent = "Copy link";
+  }, 3000);
+}
+
+/**
+ * Load settings from settings.json on the server and apply them
+ */
+async function applySettingsJSON() {
+  try {
+    const response = await fetch("settings.json");
+    const settings = await response.json();
+    if (!settings || typeof settings !== "object") {
+      return console.error("Settings are empty or malformed");
+    }
+    for (let setting in settings) {
+      testState.speedtest.setParameter(setting, settings[setting]);
+      if (
+        setting == "telemetry_level" &&
+        settings[setting] &&
+        settings[setting] != "off" &&
+        settings[setting] != "disabled" &&
+        settings[setting] != "false"
+      ) {
+        testState.telemetryEnabled = true;
+        document.querySelector("#privacy-warning").classList.remove("hidden");
+      }
+    }
+  } catch (error) {
+    console.error("Failed to fetch settings:", error);
+  }
+}
+
+/**
+ * Load server list from the configured source and populate the dropdown
+ */
+async function applyServerListJSON() {
+  try {
+    const serverSource =
+      typeof globalThis.SPEEDTEST_SERVERS !== "undefined"
+        ? globalThis.SPEEDTEST_SERVERS
+        : "server-list.json";
+    const servers = Array.isArray(serverSource)
+      ? serverSource
+      : await fetch(serverSource).then((response) => response.json());
+    if (!servers || !Array.isArray(servers) || servers.length === 0) {
+      return console.error("Server list is empty or malformed");
+    }
+
+    testState.servers = servers;
+
+    // If there's only one server, just show it. No reachability checks needed.
+    if (servers.length === 1) {
+      populateDropdown(servers);
+      return;
+    }
+
+    // For multiple servers: first run the built-in selection (which pings servers
+    // and annotates them with pingT). Only then populate the dropdown so that
+    // dead servers don't appear.
+    testState.speedtest.addTestPoints(servers);
+    testState.speedtest.selectServer((bestServer) => {
+      const aliveServers = testState.servers.filter((s) => {
+        // Keep servers that responded to ping (pingT !== -1).
+        if (s.pingT !== -1) return true;
+        // Also keep protocol-relative servers ("//...") as a defensive fallback.
+        // LibreSpeed normalizes them to the page protocol before pinging, so they
+        // are normally treated like any other server and get a real pingT value.
+        return typeof s.server === "string" && s.server.startsWith("//");
+      });
+
+      // Prefer to show only reachable servers, but if none are reachable,
+      // fall back to the full list so users can still pick a server manually.
+      if (aliveServers.length > 0) {
+        testState.servers = aliveServers;
+      }
+      populateDropdown(testState.servers);
+
+
+      if (bestServer) {
+        selectServer(bestServer);
+      } else {
+        alert(
+          "Can't reach any of the speedtest servers! But you're on this page. Something weird is going on with your network."
+        );
+      }
+    });
+  } catch (error) {
+    console.error("Failed to load server list:", error);
+  }
+}
+
+/**
+ * Add all the servers to the server selection dropdown and make it actually
+ * work.
+ * @param {Array} servers - an array of server objects
+ */
+function populateDropdown(servers) {
+  const serverSelector = document.querySelector("div.server-selector");
+  const serverList = serverSelector.querySelector("ul.servers");
+
+  // Reset previous state (populateDropdown can be called multiple times)
+  serverSelector.classList.remove("single-server");
+  serverSelector.classList.remove("active");
+  serverList.classList.remove("active");
+  serverList.innerHTML = "";
+
+  // If we have only a single server, just show it
+  if (servers.length === 1) {
+    serverSelector.classList.add("single-server");
+    selectServer(servers[0]);
+    return;
+  }
+  serverSelector.classList.add("active");
+
+  // Make the dropdown open and close (hook only once)
+  if (serverSelector.dataset.hooked !== "1") {
+    serverSelector.dataset.hooked = "1";
+
+    serverSelector.addEventListener("click", () => {
+      serverList.classList.toggle("active");
+    });
+    document.addEventListener("click", (e) => {
+      if (e.target.closest("div.server-selector") !== serverSelector)
+        serverList.classList.remove("active");
+    });
+  }
+
+  // Populate the list to choose from
+  servers.forEach((server) => {
+    const item = document.createElement("li");
+    const link = document.createElement("a");
+    link.href = "#";
+    link.innerHTML = `${server.name}${
+      server.sponsorName ? ` <span>(${server.sponsorName})</span>` : ""
+    }`;
+    link.addEventListener("click", () => selectServer(server));
+    item.appendChild(link);
+    serverList.appendChild(item);
+  });
+}
+
+/**
+ * Set the given server as the selected server for the speedtest
+ * @param {Object} server - a server object
+ */
+function selectServer(server) {
+  testState.speedtest.setSelectedServer(server);
+  testState.selectedServerDirty = true;
+  testState.state = READY;
+}
+
+/**
+ * Start the requestAnimationFrame UI rendering loop
+ */
+function startRenderingLoop() {
+  // Do these queries once to speed up the rendering itself
+  const serverSelector = document.querySelector("div.server-selector");
+  const selectedServer = serverSelector.querySelector("#selected-server");
+  const sponsor = serverSelector.querySelector("#sponsor");
+  const startButton = document.querySelector("#start-button");
+  const privacyWarning = document.querySelector("#privacy-warning");
+
+  const gauges = document.querySelectorAll("#download-gauge, #upload-gauge");
+  const downloadProgress = document.querySelector("#download-gauge .progress");
+  const uploadProgress = document.querySelector("#upload-gauge .progress");
+  const downloadGauge = document.querySelector("#download-gauge .speed");
+  const uploadGauge = document.querySelector("#upload-gauge .speed");
+  const downloadText = document.querySelector("#download-gauge span");
+  const uploadText = document.querySelector("#upload-gauge span");
+
+  const pingAndJitter = document.querySelectorAll(".ping, .jitter");
+  const ping = document.querySelector("#ping");
+  const jitter = document.querySelector("#jitter");
+  const shareResults = document.querySelector("#share-results");
+  const copyLink = document.querySelector("#copy-link");
+  const resultsImage = document.querySelector("#results");
+
+  const buttonTexts = {
+    [INITIALIZING]: "Loading...",
+    [READY]: "Let's start",
+    [RUNNING]: "Abort",
+    [FINISHED]: "Restart",
+  };
+
+  // Show copy link button only if navigator.clipboard is available
+  copyLink.classList.toggle("hidden", !navigator.clipboard);
+
+  function renderUI() {
+    // Make the main button reflect the current state
+    startButton.textContent = buttonTexts[testState.state];
+    startButton.classList.toggle("disabled", testState.state === INITIALIZING);
+    startButton.classList.toggle("active", testState.state === RUNNING);
+
+    // Disable the server selector while test is running
+    serverSelector.classList.toggle("disabled", testState.state === RUNNING);
+
+    // Show selected server
+    if (testState.selectedServerDirty) {
+      const server = testState.speedtest.getSelectedServer();
+      selectedServer.textContent = server.name;
+      if (server.sponsorName) {
+        if (server.sponsorURL) {
+          sponsor.innerHTML = `Sponsor: <a href="${server.sponsorURL}">${server.sponsorName}</a>`;
+        } else {
+          sponsor.textContent = `Sponsor: ${server.sponsorName}`;
+        }
+      } else {
+        sponsor.innerHTML = "&nbsp;";
+      }
+      testState.selectedServerDirty = false;
+    }
+
+    // Activate the gauges when test running or finished
+    gauges.forEach((e) =>
+      e.classList.toggle(
+        "enabled",
+        testState.state === RUNNING || testState.state === FINISHED
+      )
+    );
+
+    // Show ping and jitter if data is available
+    pingAndJitter.forEach((e) =>
+      e.classList.toggle(
+        "hidden",
+        !(
+          testState.testData &&
+          testState.testData.pingStatus &&
+          testState.testData.jitterStatus
+        )
+      )
+    );
+
+    // Show share button after test if server supports it
+    shareResults.classList.toggle(
+      "hidden",
+      !(
+        testState.state === FINISHED &&
+        testState.telemetryEnabled &&
+        testState.testData.testId
+      )
+    );
+
+    if (testState.testDataDirty) {
+      // Set gauge rotations
+      downloadProgress.style = `--progress-rotation: ${
+        testState.testData.dlProgress * 180
+      }deg`;
+      uploadProgress.style = `--progress-rotation: ${
+        testState.testData.ulProgress * 180
+      }deg`;
+      downloadGauge.style = `--speed-rotation: ${mbpsToRotation(
+        testState.testData.dlStatus,
+        testState.testData.testState === 1
+      )}deg`;
+      uploadGauge.style = `--speed-rotation: ${mbpsToRotation(
+        testState.testData.ulStatus,
+        testState.testData.testState === 3
+      )}deg`;
+
+      // Set numeric values
+      downloadText.textContent = numberToText(testState.testData.dlStatus);
+      uploadText.textContent = numberToText(testState.testData.ulStatus);
+      ping.textContent = numberToText(testState.testData.pingStatus);
+      jitter.textContent = numberToText(testState.testData.jitterStatus);
+
+      // Set user's IP and provider
+      if (testState.testData.clientIp) {
+        // Clear previous content
+        privacyWarning.innerHTML = '';
+
+        const connectedThrough = document.createElement('span');
+        connectedThrough.textContent = 'You are connected through:';
+  
+        const ipAddress = document.createTextNode(testState.testData.clientIp);
+
+        privacyWarning.appendChild(connectedThrough);
+        privacyWarning.appendChild(document.createElement('br'));
+        privacyWarning.appendChild(ipAddress);
+  
+        privacyWarning.classList.remove("hidden");
+      }
+
+      // Set image for sharing results
+      if (testState.testData.testId) {
+        resultsImage.src =
+          window.location.href.substring(
+            0,
+            window.location.href.lastIndexOf("/")
+          ) +
+          "/results/?id=" +
+          testState.testData.testId;
+      }
+
+      testState.testDataDirty = false;
+    }
+
+    requestAnimationFrame(renderUI);
+  }
+
+  renderUI();
+}
+
+/**
+ * Convert a speed in Mbits per second to a rotation for the gauge
+ * @param {string} speed Speed in Mbits
+ * @param {boolean} oscillate If the gauge should wiggle a bit
+ * @returns {number} Rotation for the gauge in degrees
+ */
+function mbpsToRotation(speed, oscillate) {
+  speed = Number(speed);
+  if (speed <= 0) return 0;
+
+  const minSpeed = 0;
+  const maxSpeed = 10000; // 10 Gbps maxes out the gauge
+  const minRotation = 0;
+  const maxRotation = 180;
+
+  // Can't do log10 of values less than one, +1 all to keep it fair
+  const logMinSpeed = Math.log10(minSpeed + 1);
+  const logMaxSpeed = Math.log10(maxSpeed + 1);
+  const logSpeed = Math.log10(speed + 1);
+
+  const power = (logSpeed - logMinSpeed) / (logMaxSpeed - logMinSpeed);
+  const oscillation = oscillate ? 1 + 0.01 * Math.sin(Date.now() / 100) : 1;
+  const rotation = power * oscillation * maxRotation;
+
+  // Make sure we stay within bounds at all times
+  return Math.max(Math.min(rotation, maxRotation), minRotation);
+}
+
+/**
+ * Convert a number to a user friendly version
+ * @param {string} value Speed, ping or jitter
+ * @returns {string} A text version with proper decimals
+ */
+function numberToText(value) {
+  if (!value) return "00";
+  value = Number(value);
+  if (value < 10) return value.toFixed(2);
+  if (value < 100) return value.toFixed(1);
+  return value.toFixed(0);
+}

BIN
frontend/screenshots/desktop.png


BIN
frontend/screenshots/mobile.png


+ 409 - 0
frontend/server-list.json

@@ -0,0 +1,409 @@
+[
+  {
+    "name": "Amsterdam, Netherlands",
+    "server": "//ams.speedtest.clouvider.net/backend",
+    "id": 51,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "Clouvider",
+    "sponsorURL": "https://www.clouvider.co.uk/"
+  },
+  {
+    "name": "Amsterdam, Netherlands (Rust backend)",
+    "server": "https://librespeed-rs.ir/",
+    "id": 95,
+    "dlURL": "backend/garbage",
+    "ulURL": "backend/empty",
+    "pingURL": "backend/empty",
+    "getIpURL": "backend/getIP",
+    "sponsorName": "Sudo Dios",
+    "sponsorURL": "https://github.com/SudoDios"
+  },
+  {
+    "name": "Amsterdam, Netherlands",
+    "server": "https://amsspeed.sharktech.net",
+    "id": 94,
+    "dlURL": "backend/garbage.php",
+    "ulURL": "backend/empty.php",
+    "pingURL": "backend/empty.php",
+    "getIpURL": "backend/getIP.php",
+    "sponsorName": "Sharktech",
+    "sponsorURL": "https://sharktech.net"
+  },
+  {
+    "name": "Atlanta, United States",
+    "server": "//atl.speedtest.clouvider.net/backend",
+    "id": 53,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "Clouvider",
+    "sponsorURL": "https://www.clouvider.co.uk/"
+  },
+  {
+    "name": "Bangalore, India",
+    "server": "//in1.backend.librespeed.org/",
+    "id": 75,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "DigitalOcean",
+    "sponsorURL": "https://www.digitalocean.com"
+  },
+  {
+    "name": "Bari, Italy",
+    "server": "https://st-be-ba1.infra.garr.it",
+    "id": 33,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "Consortium GARR",
+    "sponsorURL": "//garr.it"
+  },
+  {
+    "name": "Bologna, Italy",
+    "server": "https://st-be-bo1.infra.garr.it",
+    "id": 34,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "Consortium GARR",
+    "sponsorURL": "//garr.it"
+  },
+  {
+    "name": "Chicago, USA",
+    "server": "https://chispeed.sharktech.net",
+    "id": 93,
+    "dlURL": "backend/garbage.php",
+    "ulURL": "backend/empty.php",
+    "pingURL": "backend/empty.php",
+    "getIpURL": "backend/getIP.php",
+    "sponsorName": "Sharktech",
+    "sponsorURL": "https://sharktech.net"
+  },
+  {
+    "name": "Denver, USA",
+    "server": "https://denspeed.sharktech.net",
+    "id": 92,
+    "dlURL": "backend/garbage.php",
+    "ulURL": "backend/empty.php",
+    "pingURL": "backend/empty.php",
+    "getIpURL": "backend/getIP.php",
+    "sponsorName": "Sharktech",
+    "sponsorURL": "https://sharktech.net"
+  },
+  {
+    "name": "Frankfurt, Germany",
+    "server": "//fra.speedtest.clouvider.net/backend",
+    "id": 50,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "Clouvider",
+    "sponsorURL": "https://www.clouvider.co.uk/"
+  },
+  {
+    "name": "Frankfurt, Germany (FRA01)",
+    "server": "https://speedtest.lumischvps.cloud/",
+    "id": 86,
+    "dlURL": "backend/garbage.php",
+    "ulURL": "backend/empty.php",
+    "pingURL": "backend/empty.php",
+    "getIpURL": "backend/getIP.php",
+    "sponsorName": "LumischVPS",
+    "sponsorURL": "https://discord.gg/GxYzPwJmA2"
+  },
+  {
+    "name": "Ghom, Iran (Amin IDC)",
+    "server": "https://fastme.ir/",
+    "id": 77,
+    "dlURL": "backend/garbage.php",
+    "ulURL": "backend/empty.php",
+    "pingURL": "backend/empty.php",
+    "getIpURL": "backend/getIP.php",
+    "sponsorName": "Bardia Moshiri",
+    "sponsorURL": "https://bardia.tech/"
+  },
+  {
+    "name": "Helsinki, Finland (3) (Hetzner)",
+    "server": "//finew.openspeed.org/",
+    "id": 22,
+    "dlURL": "backend437/garbage.php",
+    "ulURL": "backend437/empty.php",
+    "pingURL": "backend437/empty.php",
+    "getIpURL": "backend437/getIP.php",
+    "sponsorName": "Daily Health Insurance Group",
+    "sponsorURL": "//dhig.net/"
+  },
+  {
+    "name": "Helsinki, Finland (5) (Hetzner)",
+    "server": "//fast.kabi.tk/",
+    "id": 24,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "KABI.tk",
+    "sponsorURL": "//kabi.tk"
+  },
+  {
+    "name": "Johannesburg, South Africa",
+    "server": "//za1.backend.librespeed.org/",
+    "id": 70,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "HOSTAFRICA",
+    "sponsorURL": "https://www.hostafrica.co.za"
+  },
+  {
+    "name": "Las Vegas, USA",
+    "server": "https://lasspeed.sharktech.net",
+    "id": 90,
+    "dlURL": "backend/garbage.php",
+    "ulURL": "backend/empty.php",
+    "pingURL": "backend/empty.php",
+    "getIpURL": "backend/getIP.php",
+    "sponsorName": "Sharktech",
+    "sponsorURL": "https://sharktech.net"
+  },
+  {
+    "name": "London, England",
+    "server": "//lon.speedtest.clouvider.net/backend",
+    "id": 49,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "Clouvider",
+    "sponsorURL": "https://www.clouvider.co.uk/"
+  },
+  {
+    "name": "Los Angeles, United States (1)",
+    "server": "//la.speedtest.clouvider.net/backend",
+    "id": 54,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "Clouvider",
+    "sponsorURL": "https://www.clouvider.co.uk/"
+  },
+  {
+    "name": "Los Angeles, USA",
+    "server": "https://laxspeed.sharktech.net",
+    "id": 91,
+    "dlURL": "backend/garbage.php",
+    "ulURL": "backend/empty.php",
+    "pingURL": "backend/empty.php",
+    "getIpURL": "backend/getIP.php",
+    "sponsorName": "Sharktech",
+    "sponsorURL": "https://sharktech.net"
+  },
+  {
+    "name": "New York, United States (2)",
+    "server": "//nyc.speedtest.clouvider.net/backend",
+    "id": 52,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "Clouvider",
+    "sponsorURL": "https://www.clouvider.co.uk/"
+  },
+  {
+    "name": "Nottingham, England (LayerIP)",
+    "server": "https://uk1.backend.librespeed.org",
+    "id": 43,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "fosshost.org",
+    "sponsorURL": "https://fosshost.org"
+  },
+  {
+    "name": "Nuremberg, Germany (1) (Hetzner)",
+    "server": "//de1.backend.librespeed.org",
+    "id": 28,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "Snopyta",
+    "sponsorURL": "https://snopyta.org"
+  },
+  {
+    "name": "Nuremberg, Germany (2) (Hetzner)",
+    "server": "//de4.backend.librespeed.org",
+    "id": 27,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "LibreSpeed",
+    "sponsorURL": "https://librespeed.org"
+  },
+  {
+    "name": "Nuremberg, Germany (3) (Hetzner)",
+    "server": "//de3.backend.librespeed.org",
+    "id": 30,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "LibreSpeed",
+    "sponsorURL": "https://librespeed.org"
+  },
+  {
+    "name": "Nuremberg, Germany (4) (Hetzner)",
+    "server": "//de5.backend.librespeed.org",
+    "id": 31,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "LibreSpeed",
+    "sponsorURL": "https://librespeed.org"
+  },
+  {
+    "name": "Nuremberg, Germany (6) (Hetzner)",
+    "server": "//librespeed.lukas-heinrich.com/",
+    "id": 46,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "luki9100",
+    "sponsorURL": "https://lukas-heinrich.com/"
+  },
+  {
+    "name": "Poznan, Poland (INEA)",
+    "server": "https://speedtest.kamilszczepanski.com",
+    "id": 74,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "Kamil Szczepa\u0144ski",
+    "sponsorURL": "https://kamilszczepanski.com"
+  },
+  {
+    "name": "Prague, Czech Republic",
+    "server": "//speedtest.cesnet.cz",
+    "id": 79,
+    "dlURL": "backend/garbage.php",
+    "ulURL": "backend/empty.php",
+    "pingURL": "backend/empty.php",
+    "getIpURL": "backend/getIP.php",
+    "sponsorName": "CESNET",
+    "sponsorURL": "https://www.cesnet.cz"
+  },
+  {
+    "name": "Prague, Czech Republic",
+    "server": "//librespeed.turris.cz",
+    "id": 85,
+    "dlURL": "backend/garbage.php",
+    "ulURL": "backend/empty.php",
+    "pingURL": "backend/empty.php",
+    "getIpURL": "backend/getIP.php",
+    "sponsorName": "Turris",
+    "sponsorURL": "https://www.turris.com"
+  },
+  {
+    "name": "Roma, Italy",
+    "server": "https://st-be-rm2.infra.garr.it",
+    "id": 35,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "Consortium GARR",
+    "sponsorURL": "//garr.it"
+  },
+  {
+    "name": "Serbia",
+    "server": "https://speedtest2.sox.rs",
+    "id": 87,
+    "dlURL": "libre/backend/garbage.php",
+    "ulURL": "libre/backend/empty.php",
+    "pingURL": "libre/backend/empty.php",
+    "getIpURL": "libre/backend/getIP.php",
+    "sponsorName": "Serbian Open eXchange",
+    "sponsorURL": "https://sox.rs"
+  },
+  {
+    "name": "Singapore",
+    "server": "https://speedtest.dsgroupmedia.com",
+    "id": 68,
+    "dlURL": "backend/garbage.php",
+    "ulURL": "backend/empty.php",
+    "pingURL": "backend/empty.php",
+    "getIpURL": "backend/getIP.php",
+    "sponsorName": "Salvatore Cahyo",
+    "sponsorURL": "https://salvatorecahyo.my.id"
+  },
+  {
+    "name": "Tehran, Iran (Fanava)",
+    "server": "https://speedme.ir/",
+    "id": 76,
+    "dlURL": "backend/garbage.php",
+    "ulURL": "backend/empty.php",
+    "pingURL": "backend/empty.php",
+    "getIpURL": "backend/getIP.php",
+    "sponsorName": "Bardia Moshiri",
+    "sponsorURL": "https://bardia.tech"
+  },
+  {
+    "name": "Tehran, Iran (Faraso)",
+    "server": "https://st.bardia.tech",
+    "id": 80,
+    "dlURL": "backend/garbage.php",
+    "ulURL": "backend/empty.php",
+    "pingURL": "backend/empty.php",
+    "getIpURL": "backend/getIP.php",
+    "sponsorName": "Bardia Moshiri",
+    "sponsorURL": "https://bardia.tech/"
+  },
+  {
+    "name": "Tokyo, Japan",
+    "server": "https://librespeed.a573.net/",
+    "id": 82,
+    "dlURL": "backend/garbage.php",
+    "ulURL": "backend/empty.php",
+    "pingURL": "backend/empty.php",
+    "getIpURL": "backend/getIP.php",
+    "sponsorName": "A573",
+    "sponsorURL": "https://mirror.a573.net/"
+  },
+  {
+    "name": "Vilnius, Lithuania (RackRay)",
+    "server": "//lt1.backend.librespeed.org/",
+    "id": 69,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "Time4VPS",
+    "sponsorURL": "https://www.time4vps.com"
+  },
+  {
+    "name": "Virginia, United States, OVH",
+    "server": "https://speed.riverside.rocks/",
+    "id": 78,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "Riverside Rocks",
+    "sponsorURL": "https://riverside.rocks"
+  }
+]

+ 9 - 0
frontend/settings.json

@@ -0,0 +1,9 @@
+{
+  "telemetry_level": "off",
+  "test_order": "ID_U_P",
+  "time_dl_max": 12,
+  "time_ul_max": 12,
+  "time_dlGraceTime": 0,
+  "time_ulGraceTime": 0,
+  "time_auto": false
+}

+ 83 - 0
frontend/styling/button.css

@@ -0,0 +1,83 @@
+/* The main "start the test" button and the share button */
+
+button {
+  height: 6.8rem;
+  min-width: 26.4rem;
+  padding: 0 5rem;
+  margin: 2.5rem;
+  border-radius: 3.4rem;
+  border: 0;
+
+  font-family: "Inter", sans-serif;
+  font-size: 2rem;
+  font-weight: 700;
+  letter-spacing: -0.1rem;
+  color: var(--button-text-color);
+  text-transform: uppercase;
+  cursor: pointer;
+  box-shadow: 0 0.4rem 1.6rem 0 var(--button-shadow-color);
+
+  will-change: transform;
+  backface-visibility: hidden;
+  transform: scale(1) translate3d(0, 0, 0) perspective(1px);
+
+  background: var(--button-gradient-1-color-1);
+  transition: background-position 0.2s, transform 0.2s;
+  background-position: 0% 0%;
+  background: linear-gradient(
+    92.97deg,
+    var(--button-gradient-1-color-1) 0%,
+    var(--button-gradient-1-color-1) 33%,
+    var(--button-gradient-1-color-2) 40%,
+    var(--button-gradient-1-color-3) 66.71%,
+    var(--button-gradient-1-color-3) 100%
+  );
+  background-size: 300% 100%;
+
+  &.disabled {
+    cursor: default;
+    transform: scale(1) translate3d(0, 0, 0) perspective(1px);
+    background: var(--button-disabled-background-color);
+  }
+  &.small {
+    height: 4.7rem;
+    min-width: 20.2rem;
+    text-transform: lowercase;
+  }
+  &.inverted {
+    border: 1px solid var(--button-gradient-1-color-1);
+    color: transparent;
+    background-clip: text;
+  }
+  &.hidden {
+    opacity: 0;
+    pointer-events: none;
+  }
+  &:hover {
+    background-position: 60% 0%;
+    transform: scale(1.03) translate3d(0, 0, 0) perspective(1px);
+  }
+  &.active,
+  &:active {
+    background-position: 100% 0%;
+    animation: pulse 0.7s;
+  }
+}
+
+@keyframes pulse {
+  0% {
+    transform: scale(1.03) translate3d(0, 0, 0) perspective(1px);
+  }
+  20% {
+    transform: scale(1.2) translate3d(0, 0, 0) perspective(1px);
+  }
+  40% {
+    transform: scale(1) translate3d(0, 0, 0) perspective(1px);
+  }
+  60% {
+    transform: scale(1.1) translate3d(0, 0, 0) perspective(1px);
+  }
+  100% {
+    transform: scale(1) translate3d(0, 0, 0) perspective(1px);
+  }
+}

+ 36 - 0
frontend/styling/colors.css

@@ -0,0 +1,36 @@
+:root {
+  --theme-green: #5cf9fd;
+  --theme-pink: #d63bc6;
+
+  --background-backup-color: #0e0720;
+  --background-overlay-color: rgb(41 26 70 / 71%);
+
+  --primary-text-color: #ffffff;
+  --tagline-text-color: var(--theme-green);
+  --secondary-text-color: #898591;
+  --primary-text-disabled-color: #888888;
+  --secondary-text-disabled-color: #2e7d7f;
+
+  --button-text-color: #3e2f50;
+  --button-gradient-1-color-1: #f5f5f5;
+  --button-gradient-1-color-2: var(--theme-green);
+  --button-gradient-1-color-3: var(--theme-pink);
+  --button-shadow-color: #5cf9fd47;
+  --button-disabled-background-color: #a2a2a2;
+
+  --server-selector-border-color: #625b6b;
+  --server-selector-hover-border-color: var(--theme-green);
+  --server-selector-background-color: #251b32;
+  --server-selector-hover-background-color: var(--server-selector-border-color);
+
+  --gauge-background-color: #3e2f50;
+  --gauge-progress-color: #726c7a;
+  --gauge-pointer-green: #e2fbfc;
+  --gauge-pointer-pink: #d091ca;
+
+  --ping-and-jitter-primary-text-color: #f5f5f5;
+  --ping-and-jitter-secondary-text-color: #7b7b7b;
+
+  --popup-background-color: #251b32;
+  --popup-shadow-color: #000000;
+}

+ 132 - 0
frontend/styling/dialog.css

@@ -0,0 +1,132 @@
+/* Styling for the popups */
+
+dialog {
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  width: 70vw;
+  height: 70vh;
+  margin: auto;
+  margin-top: 23rem;
+
+  background: var(--popup-background-color);
+  border: none;
+  border-radius: 0.8rem;
+
+  @media screen and (max-width: 800px) {
+    position: fixed;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    max-width: 100vw; /* We need these overrides of browser defaults*/
+    max-height: 100vh;
+    width: auto;
+    height: auto;
+    margin: 0;
+  }
+
+  animation: fade-out 0.3s ease-out;
+  &[open] {
+    display: flex;
+    animation: fade-in 0.3s ease-out;
+  }
+
+  & > .close-dialog {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 4rem;
+    height: 4rem;
+    position: absolute;
+    top: 3rem;
+    right: 3rem;
+
+    cursor: pointer;
+  }
+
+  & > section {
+    max-width: 800px;
+    overflow-y: auto;
+    margin: 4rem 2rem 2rem 4rem;
+    padding: 0 2rem 0 0;
+
+    & h1,
+    & h2 {
+      margin: 3rem 0 2rem 0;
+      font-size: 3.6rem;
+      font-weight: 400;
+      letter-spacing: -0.2rem;
+      color: var(--primary-text-color);
+    }
+    & h2 {
+      margin: 2rem 0 1rem 0;
+      font-size: 2.5rem;
+    }
+
+    & p,
+    & li {
+      margin: 1rem 0 1rem 0;
+      font-size: 1.6rem;
+      line-height: 2.5rem;
+      font-weight: 400;
+      letter-spacing: -0.1rem;
+      color: var(--secondary-text-color);
+    }
+
+    & ul {
+      list-style-position: inside;
+      margin: 1rem;
+
+      & li {
+        margin: 0.1rem 0;
+      }
+    }
+
+    & a {
+      font-size: 1.6rem;
+      font-weight: 700;
+      letter-spacing: -0.1rem;
+      color: var(--secondary-text-color);
+      text-underline-offset: 0.3rem;
+      transition: text-underline-offset 0.2s;
+
+      &:hover {
+        color: var(--theme-green);
+        text-underline-offset: 0.5rem;
+      }
+    }
+  }
+}
+
+@keyframes fade-in {
+  0% {
+    opacity: 0;
+    transform: scale(0.6);
+    display: none;
+  }
+  0.1% {
+    display: flex;
+  }
+  100% {
+    opacity: 1;
+    transform: scale(1);
+    display: flex;
+  }
+}
+
+@keyframes fade-out {
+  0% {
+    opacity: 1;
+    transform: scale(1);
+    display: flex;
+  }
+  99.9% {
+    display: flex;
+  }
+  100% {
+    opacity: 0;
+    transform: scale(0.6);
+    display: none;
+  }
+}

+ 22 - 0
frontend/styling/fonts.css

@@ -0,0 +1,22 @@
+/* latin-ext */
+@font-face {
+  font-family: "Inter";
+  font-style: normal;
+  font-weight: 100 900;
+  font-display: swap;
+  src: url(../fonts/Inter-latin-ext.woff2) format("woff2");
+  unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF,
+    U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+
+/* latin */
+@font-face {
+  font-family: "Inter";
+  font-style: normal;
+  font-weight: 100 900;
+  font-display: swap;
+  src: url(../fonts/Inter-latin.woff2) format("woff2");
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
+    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
+    U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}

+ 85 - 0
frontend/styling/index.css

@@ -0,0 +1,85 @@
+/**
+ * Design by fromScratch Studio - 2022, 2023 (fromscratch.io)
+ * Implementation in HTML/CSS/JS by Timendus - 2024 (https://github.com/Timendus)
+ *
+ * See https://github.com/librespeed/speedtest/issues/585
+ */
+
+@import url("colors.css");
+@import url("fonts.css");
+@import url("main.css");
+@import url("server-selector.css");
+@import url("button.css");
+@import url("results.css");
+@import url("dialog.css");
+
+/* Setting up the basic structure */
+
+* {
+  box-sizing: border-box;
+  padding: 0;
+  margin: 0;
+}
+
+html,
+body {
+  min-height: 100vh;
+  width: 100vw;
+}
+
+html {
+  background-color: var(--background-backup-color);
+  background-image: url("../images/background.jpeg");
+  background-repeat: no-repeat;
+  background-position: center;
+  background-size: cover;
+  font-size: 10px;
+
+  @media screen and (max-width: 800px) {
+    font-size: 8px;
+  }
+}
+
+body {
+  font-family: "Inter", sans-serif;
+  background-color: var(--background-overlay-color);
+  color: var(--primary-text-color);
+  display: flex;
+  flex-direction: column;
+}
+
+/* Position the logo */
+
+header {
+  padding: 4rem 7rem;
+
+  @media screen and (max-width: 800px) {
+    padding: 7rem 2rem;
+    text-align: center;
+  }
+}
+
+/* Position the source code link */
+
+footer {
+  margin: auto auto 0 auto;
+  padding: 5rem;
+
+  & > p.source a {
+    font-size: 1.6rem;
+    font-weight: 700;
+    letter-spacing: -0.1rem;
+    color: var(--theme-green);
+    text-underline-offset: 0.3rem;
+    transition: text-underline-offset 0.2s;
+
+    &:hover {
+      color: var(--theme-pink);
+      text-underline-offset: 0.5rem;
+    }
+  }
+
+  @media screen and (max-width: 800px) {
+    padding: 4rem;
+  }
+}

+ 58 - 0
frontend/styling/main.css

@@ -0,0 +1,58 @@
+/* Texts on the front page */
+
+main {
+  text-align: center;
+  padding: 0 2rem;
+  flex: 1;
+
+  & > h1 {
+    margin: 0.2rem;
+    font-size: 3.6rem;
+    font-weight: 400;
+    letter-spacing: -0.2rem;
+    color: var(--primary-text-color);
+  }
+
+  & p {
+    margin-top: 8rem;
+    font-size: 1.6rem;
+    line-height: 2.5rem;
+    font-weight: 400;
+    letter-spacing: -0.1rem;
+    color: var(--secondary-text-color);
+
+    &#privacy-warning {
+      min-height: 5.3rem;
+
+      & > span {
+        font-weight: 700;
+        color: var(--theme-green);
+      }
+      &.hidden {
+        opacity: 0;
+        pointer-events: none;
+      }
+    }
+  }
+
+  & > p.tagline {
+    margin-top: 0;
+    margin-bottom: 6rem;
+    font-size: 2rem;
+    color: var(--tagline-text-color);
+  }
+
+  & a {
+    font-size: 1.6rem;
+    font-weight: 700;
+    letter-spacing: -0.1rem;
+    color: var(--secondary-text-color);
+    text-underline-offset: 0.3rem;
+    transition: text-underline-offset 0.2s;
+
+    &:hover {
+      color: var(--theme-green);
+      text-underline-offset: 0.5rem;
+    }
+  }
+}

+ 260 - 0
frontend/styling/results.css

@@ -0,0 +1,260 @@
+/* Variables */
+
+:root {
+  --gauge-width: 32rem;
+  --gauge-height: 22rem;
+  --progress-width: 0.6rem;
+  --speed-width: 3rem;
+}
+
+/* Layout for the gauges */
+
+.gauge-layout {
+  display: flex;
+  flex-direction: row;
+  align-items: start;
+  justify-content: center;
+  gap: 5rem;
+  margin: 5rem auto 3rem auto;
+
+  @media screen and (max-width: 1100px) {
+    display: grid;
+    grid-template-areas:
+      "download upload"
+      "ping jitter";
+    justify-items: center;
+    justify-content: center;
+
+    --gauge-width: min(40vw, 32rem);
+    --gauge-height: min(28vw, 22rem);
+    --progress-width: min(1.2vw, 0.6rem);
+    --speed-width: min(4vw, 3rem);
+  }
+  @media screen and (max-width: 500px) {
+    gap: 5rem 2rem;
+  }
+}
+
+/* The download/upload speed gauges */
+
+/**
+ * One thing I should really document here is the weird `transform: scale(1);`
+ * and `position: fixed` in this code. This is a nasty little trick to allow the
+ * gauge pointer to break out of the `overflow: hidden` of the .speed element.
+ * We need the `overflow: hidden` to hide the arc that's rotating into view when
+ * the value goes up. But we do want to see the full pointer, even when it's at
+ * zero. This degrades fairly gracefully into showing half of the pointer when
+ * browsers don't understand this.
+ *
+ * Trick taken from this article:
+ * https://medium.com/@thomas.ryu/css-overriding-the-parents-overflow-hidden-90c75a0e7296
+ */
+
+div.gauge {
+  position: relative;
+  transform: scale(1);
+  width: var(--gauge-width);
+  height: var(--gauge-height);
+
+  &.download {
+    grid-area: download;
+  }
+  &.upload {
+    grid-area: upload;
+  }
+
+  & > .progress,
+  & > .speed {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: var(--gauge-width);
+    height: calc(var(--gauge-width) / 2);
+    overflow: hidden;
+
+    &:after,
+    &:before {
+      content: "";
+      position: absolute;
+      box-sizing: border-box;
+    }
+  }
+
+  & > .progress {
+    &:before,
+    &:after {
+      top: 0;
+      left: 0;
+      width: var(--gauge-width);
+      height: calc(var(--gauge-width) / 2);
+
+      border-radius: 50% 50% 0 0 / 100% 100% 0 0;
+      border: var(--progress-width) solid var(--gauge-background-color);
+      border-bottom: 0;
+
+      transform-origin: bottom center;
+      transform: rotate(var(--progress-rotation));
+      transition: transform 0.2s linear;
+    }
+    &:after {
+      top: calc(var(--gauge-width) / 2);
+
+      border-radius: 0 0 50% 50% / 0 0 100% 100%;
+      border: var(--progress-width) solid var(--gauge-background-color);
+      border-top: 0;
+
+      transform-origin: top center;
+    }
+  }
+
+  & > .speed {
+    &:before,
+    &:after {
+      transform: rotate(var(--speed-rotation));
+      transition: transform 0.2s ease;
+      transition-timing-function: cubic-bezier(0.56, 0.04, 0.59, 0.91);
+    }
+    &:before {
+      position: fixed;
+      top: calc(var(--gauge-width) / 2 - var(--speed-width) / 3);
+      left: var(--progress-width);
+      width: 0;
+      height: 0;
+
+      border-top: calc(var(--speed-width) / 3) solid transparent;
+      border-bottom: calc(var(--speed-width) / 3) solid transparent;
+      border-right: calc(var(--speed-width) * 0.97) solid
+        var(--gauge-background-color);
+      z-index: 1;
+
+      transform-origin: calc(var(--gauge-width) / 2 - var(--progress-width))
+        calc(var(--speed-width) / 3);
+    }
+    &:after {
+      top: calc(var(--gauge-width) / 2);
+      left: calc(var(--progress-width) - 0.1rem);
+      width: calc(var(--gauge-width) - var(--progress-width) * 2 + 0.2rem);
+      height: calc(var(--gauge-width) / 2 - var(--progress-width) + 0.1rem);
+
+      border-radius: 0 0 50% 50% / 0 0 100% 100%;
+      border: var(--speed-width) solid var(--gauge-background-color);
+      border-top: 0;
+
+      transform-origin: top center;
+    }
+  }
+
+  &.enabled {
+    &.download {
+      & > .progress:after {
+        border-color: var(--theme-pink);
+      }
+      & > .speed {
+        &:before {
+          border-right-color: var(--gauge-pointer-pink);
+        }
+        &:after {
+          border-color: var(--theme-pink);
+        }
+      }
+    }
+    &.upload {
+      & > .progress:after {
+        border-color: var(--theme-green);
+      }
+      & > .speed {
+        &:before {
+          border-right-color: var(--gauge-pointer-green);
+        }
+        &:after {
+          border-color: var(--theme-green);
+        }
+      }
+    }
+    & > h1 > span {
+      color: var(--primary-text-color);
+    }
+  }
+
+  & > h1,
+  & > h2 {
+    display: block;
+    position: absolute;
+    width: 100%;
+    font-family: "Inter", sans-serif;
+    font-size: 2.1rem;
+    letter-spacing: -0.1rem;
+    color: var(--secondary-text-color);
+  }
+  & > h1 {
+    bottom: calc(var(--gauge-height) - var(--gauge-width) / 2);
+    font-weight: 300;
+
+    & > span {
+      font-size: 5.5rem;
+      font-weight: 200;
+      display: block;
+      color: var(--secondary-text-color);
+      letter-spacing: -0.3rem;
+    }
+  }
+  & > h2 {
+    bottom: 0;
+    font-weight: 700;
+    text-transform: uppercase;
+  }
+
+  @media screen and (max-width: 500px) {
+    & > h1 {
+      font-size: 3vw;
+
+      & > span {
+        font-size: 8vw;
+      }
+    }
+
+    & > h2 {
+      font-size: 3vw;
+    }
+  }
+}
+
+/* Styling for Ping and Jitter */
+
+.ping,
+.jitter {
+  grid-area: jitter;
+  display: flex;
+  align-items: end;
+  height: calc(var(--gauge-width) / 2);
+  width: 13rem;
+
+  font-size: 2.1rem;
+  letter-spacing: -0.1rem;
+  font-weight: 300;
+  color: var(--ping-and-jitter-secondary-text-color);
+
+  & > .label {
+    font-weight: 700;
+  }
+  & > .value {
+    color: var(--ping-and-jitter-primary-text-color);
+  }
+
+  &.hidden {
+    display: none;
+  }
+
+  @media screen and (max-width: 1100px) {
+    width: 100%;
+    height: auto;
+    justify-content: center !important;
+  }
+  @media screen and (max-width: 500px) {
+    font-size: 1.8rem;
+  }
+}
+.ping {
+  grid-area: ping;
+  justify-content: end;
+}

+ 171 - 0
frontend/styling/server-selector.css

@@ -0,0 +1,171 @@
+/* The server selector fake dropdown */
+
+.server-selector {
+  position: relative;
+  width: 50rem;
+  margin: 0rem auto;
+  display: none;
+
+  &.active {
+    display: block;
+  }
+
+  @media screen and (max-width: 500px) {
+    width: 100%;
+  }
+
+  & > .chosen {
+    position: relative;
+    height: 8.8rem;
+
+    border: 1px solid var(--server-selector-border-color);
+    border-radius: 0.8rem;
+    background-color: var(--server-selector-background-color);
+    cursor: pointer;
+    transition: border-color 0.2s;
+
+    &:hover {
+      border-color: var(--server-selector-hover-border-color);
+    }
+
+    & > div.chevron {
+      content: "";
+      position: absolute;
+      display: block;
+      width: 32px;
+      height: 32px;
+      right: 1.8rem;
+      top: 1rem;
+    }
+
+    & > p {
+      margin: 0;
+      position: absolute;
+      left: 2.4rem;
+      top: 1.5rem;
+      font-size: 1.6rem;
+      font-weight: 400;
+      letter-spacing: -0.1rem;
+      color: var(--theme-green);
+    }
+
+    & > h2 {
+      position: absolute;
+      left: 2.4rem;
+      right: 2.4rem;
+      bottom: 1rem;
+
+      font-size: 2.4rem;
+      font-weight: 700;
+      letter-spacing: -0.2rem;
+      color: var(--primary-text-color);
+      text-align: left;
+      text-transform: uppercase;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      overflow: hidden;
+
+      & span {
+        font-weight: 400;
+      }
+    }
+  }
+
+  /* Special case for when we have only one server */
+  &.single-server {
+    & > .chosen {
+      cursor: default;
+      &:hover {
+        border-color: var(--server-selector-border-color);
+      }
+      & > div.chevron {
+        display: none;
+      }
+    }
+  }
+
+  /* Overrides for when the test is running and the selector is disabled */
+  &.disabled {
+    pointer-events: none;
+
+    & > .chosen {
+      cursor: default;
+      &:hover {
+        border-color: var(--server-selector-border-color);
+      }
+      & > p {
+        color: var(--secondary-text-disabled-color);
+      }
+      & > h2 {
+        color: var(--primary-text-disabled-color);
+      }
+    }
+  }
+
+  /* Styling for the list of servers that pops out */
+  & > ul.servers {
+    position: absolute;
+    width: 50rem;
+    max-height: 70vh;
+    overflow-y: auto;
+    z-index: 1;
+
+    border: 1px solid var(--server-selector-border-color);
+    border-radius: 0.8rem;
+    background-color: var(--server-selector-background-color);
+    list-style: none;
+
+    transform: scaleY(0);
+    transform-origin: top;
+    transition: transform 0.1s;
+
+    &.active {
+      transform: scaleY(1);
+    }
+
+    @media screen and (max-width: 800px) {
+      width: 100%;
+    }
+
+    & > li {
+      &:first-child a {
+        padding-top: 1.5rem;
+      }
+      &:last-child a {
+        padding-bottom: 1.5rem;
+      }
+
+      & a {
+        display: block;
+        padding: 0.7rem 2.4rem;
+
+        font-size: 2.4rem;
+        font-weight: 700;
+        letter-spacing: -0.2rem;
+        color: var(--sprint-text-color);
+        text-transform: uppercase;
+        text-decoration: none;
+        text-align: left;
+        cursor: pointer;
+
+        transition: background-color 0.2s;
+
+        & span {
+          font-weight: 400;
+        }
+        &:hover {
+          background-color: var(--server-selector-hover-background-color);
+        }
+      }
+    }
+  }
+
+  /* Styling for the sponsor text under the dropdown */
+  & > p.sponsor {
+    margin: 1rem 0 5rem 0;
+
+    & a {
+      font-weight: 400;
+    }
+  }
+}

+ 590 - 0
index-classic.html

@@ -0,0 +1,590 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+	<link rel="shortcut icon" href="favicon.ico">
+	<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no" />
+	<meta charset="UTF-8" />
+	<script type="text/javascript" src="speedtest.js"></script>
+	<script type="text/javascript">
+		function I(i) { return document.getElementById(i); }
+
+		//LIST OF TEST SERVERS. Leave empty if you're doing a standalone installation. See documentation for details
+		var SPEEDTEST_SERVERS = [
+			/*{	//this server doesn't actually exist, remove it
+				name:"Example Server 1", //user friendly name for the server
+				server:"//test1.mydomain.com/", //URL to the server. // at the beginning will be replaced with http:// or https:// automatically
+				dlURL:"backend/garbage.php",  //path to download test on this server (garbage.php or replacement)
+				ulURL:"backend/empty.php",  //path to upload test on this server (empty.php or replacement)
+				pingURL:"backend/empty.php",  //path to ping/jitter test on this server (empty.php or replacement)
+				getIpURL:"backend/getIP.php"  //path to getIP on this server (getIP.php or replacement)
+			},
+			{	//this server doesn't actually exist, remove it
+				name:"Example Server 2", //user friendly name for the server
+				server:"//test2.example.com/", //URL to the server. // at the beginning will be replaced with http:// or https:// automatically
+				dlURL:"garbage.php",  //path to download test on this server (garbage.php or replacement)
+				ulURL:"empty.php",  //path to upload test on this server (empty.php or replacement)
+				pingURL:"empty.php",  //path to ping/jitter test on this server (empty.php or replacement)
+				getIpURL:"getIP.php"  //path to getIP on this server (getIP.php or replacement)
+			}*/
+			//add other servers here, comma separated
+		];
+
+		//INITIALIZE SPEEDTEST
+		var s = new Speedtest(); //create speed test object
+		s.setParameter("telemetry_level", "basic"); //enable basic telemetry (for results sharing)
+
+		//SERVER AUTO SELECTION
+		function initServers() {
+			if (SPEEDTEST_SERVERS.length == 0) { //standalone installation
+				//just make the UI visible
+				I("loading").className = "hidden";
+				I("serverArea").style.display = "none";
+				I("testWrapper").className = "visible";
+				initUI();
+			} else { //multiple servers
+				var noServersAvailable = function () {
+					I("message").innerHTML = "No servers available";
+				}
+				var runServerSelect = function () {
+					s.selectServer(function (server) {
+						if (server != null) { //at least 1 server is available
+							I("loading").className = "hidden"; //hide loading message
+							//populate server list for manual selection
+							for (var i = 0; i < SPEEDTEST_SERVERS.length; i++) {
+								if (SPEEDTEST_SERVERS[i].pingT == -1) continue;
+								var option = document.createElement("option");
+								option.value = i;
+								option.textContent = SPEEDTEST_SERVERS[i].name;
+								if (SPEEDTEST_SERVERS[i] === server) option.selected = true;
+								I("server").appendChild(option);
+							}
+							//show test UI
+							I("testWrapper").className = "visible";
+							initUI();
+						} else { //no servers are available, the test cannot proceed
+							noServersAvailable();
+						}
+					});
+				}
+				if (typeof SPEEDTEST_SERVERS === "string") {
+					//need to fetch list of servers from specified URL
+					s.loadServerList(SPEEDTEST_SERVERS, function (servers) {
+						if (servers == null) { //failed to load server list
+							noServersAvailable();
+						} else { //server list loaded
+							SPEEDTEST_SERVERS = servers;
+							runServerSelect();
+						}
+					});
+				} else {
+					//hardcoded server list
+					s.addTestPoints(SPEEDTEST_SERVERS);
+					runServerSelect();
+				}
+			}
+		}
+
+		var meterBk = /Trident.*rv:(\d+\.\d+)/i.test(navigator.userAgent) ? "#EAEAEA" : "#80808040";
+		var dlColor = "#6060AA",
+			ulColor = "#616161";
+		var progColor = meterBk;
+
+		//CODE FOR GAUGES
+		function drawMeter(c, amount, bk, fg, progress, prog) {
+			var ctx = c.getContext("2d");
+			var dp = window.devicePixelRatio || 1;
+			var cw = c.clientWidth * dp, ch = c.clientHeight * dp;
+			var sizScale = ch * 0.0055;
+			if (c.width == cw && c.height == ch) {
+				ctx.clearRect(0, 0, cw, ch);
+			} else {
+				c.width = cw;
+				c.height = ch;
+			}
+			ctx.beginPath();
+			ctx.strokeStyle = bk;
+			ctx.lineWidth = 12 * sizScale;
+			ctx.arc(c.width / 2, c.height - 58 * sizScale, c.height / 1.8 - ctx.lineWidth, -Math.PI * 1.1, Math.PI * 0.1);
+			ctx.stroke();
+			ctx.beginPath();
+			ctx.strokeStyle = fg;
+			ctx.lineWidth = 12 * sizScale;
+			ctx.arc(c.width / 2, c.height - 58 * sizScale, c.height / 1.8 - ctx.lineWidth, -Math.PI * 1.1, amount * Math.PI * 1.2 - Math.PI * 1.1);
+			ctx.stroke();
+			if (typeof progress !== "undefined") {
+				ctx.fillStyle = prog;
+				ctx.fillRect(c.width * 0.3, c.height - 16 * sizScale, c.width * 0.4 * progress, 4 * sizScale);
+			}
+		}
+		function mbpsToAmount(s) {
+			return 1 - (1 / (Math.pow(1.3, Math.sqrt(s))));
+		}
+		function format(d) {
+			d = Number(d);
+			if (d < 10) return d.toFixed(2);
+			if (d < 100) return d.toFixed(1);
+			return d.toFixed(0);
+		}
+
+		//UI CODE
+		var uiData = null;
+		function startStop() {
+			if (s.getState() == 3) {
+				//speed test is running, abort
+				s.abort();
+				data = null;
+				I("startStopBtn").className = "";
+				I("startStopBtn").setAttribute("aria-label", "Start");
+				I("server").disabled = false;
+				initUI();
+			} else {
+				//test is not running, begin
+				I("startStopBtn").className = "running";
+				I("startStopBtn").setAttribute("aria-label", "Abort");
+				I("shareArea").style.display = "none";
+				I("server").disabled = true;
+				s.onupdate = function (data) {
+					uiData = data;
+				};
+				s.onend = function (aborted) {
+					I("startStopBtn").className = "";
+					I("startStopBtn").setAttribute("aria-label", "Start");
+					I("server").disabled = false;
+					updateUI(true);
+					if (!aborted) {
+						//if testId is present, show sharing panel, otherwise do nothing
+						try {
+							var testId = uiData.testId;
+							if (testId != null) {
+								var shareURL = window.location.href.substring(0, window.location.href.lastIndexOf("/")) + "/results/?id=" + testId;
+								I("resultsImg").src = shareURL;
+								I("resultsURL").value = shareURL;
+								I("testId").innerHTML = testId;
+								I("shareArea").style.display = "";
+							}
+						} catch (e) { }
+					}
+				};
+				s.start();
+			}
+		}
+		//this function reads the data sent back by the test and updates the UI
+		function updateUI(forced) {
+			if (!forced && s.getState() != 3) return;
+			if (uiData == null) return;
+			var status = uiData.testState;
+			I("ip").textContent = uiData.clientIp;
+			I("dlText").textContent = (status == 1 && uiData.dlStatus == 0) ? "..." : format(uiData.dlStatus);
+			drawMeter(I("dlMeter"), mbpsToAmount(Number(uiData.dlStatus * (status == 1 ? oscillate() : 1))), meterBk, dlColor, Number(uiData.dlProgress), progColor);
+			I("ulText").textContent = (status == 3 && uiData.ulStatus == 0) ? "..." : format(uiData.ulStatus);
+			drawMeter(I("ulMeter"), mbpsToAmount(Number(uiData.ulStatus * (status == 3 ? oscillate() : 1))), meterBk, ulColor, Number(uiData.ulProgress), progColor);
+			I("pingText").textContent = format(uiData.pingStatus);
+			I("jitText").textContent = format(uiData.jitterStatus);
+		}
+		function oscillate() {
+			return 1 + 0.02 * Math.sin(Date.now() / 100);
+		}
+		//update the UI every frame
+		window.requestAnimationFrame = window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || window.msRequestAnimationFrame || (function (callback, element) { setTimeout(callback, 1000 / 60); });
+		function frame() {
+			requestAnimationFrame(frame);
+			updateUI();
+		}
+		frame(); //start frame loop
+		//function to (re)initialize UI
+		function initUI() {
+			drawMeter(I("dlMeter"), 0, meterBk, dlColor, 0);
+			drawMeter(I("ulMeter"), 0, meterBk, ulColor, 0);
+			I("dlText").textContent = "";
+			I("ulText").textContent = "";
+			I("pingText").textContent = "";
+			I("jitText").textContent = "";
+			I("ip").textContent = "";
+		}
+	</script>
+	<style type="text/css">
+		html,
+		body {
+			border: none;
+			padding: 0;
+			margin: 0;
+			background: #FFFFFF;
+			color: #202020;
+		}
+
+		body {
+			text-align: center;
+			font-family: "Roboto", sans-serif;
+		}
+
+		h1 {
+			color: #404040;
+		}
+
+		#loading {
+			background-color: #FFFFFF;
+			color: #404040;
+			text-align: center;
+		}
+
+		span.loadCircle {
+			display: inline-block;
+			width: 2em;
+			height: 2em;
+			vertical-align: middle;
+			background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAAAP1BMVEUAAAB2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZyFzwnAAAAFHRSTlMAEvRFvX406baecwbf0casimhSHyiwmqgAAADpSURBVHja7dbJbQMxAENRahnN5lkc//5rDRAkDeRgHszXgACJoKiIiIiIiIiIiIiIiIiIiIj4HHspsrpAVhdVVguzrA4OWc10WcEqpwKbnBo0OU1Q5NSpsoJFTgOecrrdEag85DRgktNqfoEdTjnd7hrEHMEJvmRUYJbTYk5Agy6nau6Abp5Cm7mDBtRdPi9gyKdU7w4p1fsLvyqs8hl4z9/w3n/Hmr9WoQ65lAU4d7lMYOz//QboRR5jBZibLMZdAR6O/Vfa1PlxNr3XdS3HzK/HVPRu/KnLs8iAOh993VpRRERERMT/fAN60wwWaVyWwAAAAABJRU5ErkJggg==');
+			background-size: 2em 2em;
+			margin-right: 0.5em;
+			animation: spin 0.6s linear infinite;
+		}
+
+		@keyframes spin {
+			0% {
+				transform: rotate(0deg);
+			}
+
+			100% {
+				transform: rotate(359deg);
+			}
+		}
+
+		#startStopBtn {
+			display: inline-block;
+			margin: 0 auto;
+			color: #6060AA;
+			background-color: rgba(0, 0, 0, 0);
+			border: 0.15em solid #6060FF;
+			padding: 0;
+			font: inherit;
+			border-radius: 0.3em;
+			transition: all 0.3s;
+			box-sizing: border-box;
+			width: 8em;
+			height: 3em;
+			line-height: 2.7em;
+			cursor: pointer;
+			box-shadow: 0 0 0 rgba(0, 0, 0, 0.1), inset 0 0 0 rgba(0, 0, 0, 0.1);
+		}
+
+		#startStopBtn:hover {
+			box-shadow: 0 0 2em rgba(0, 0, 0, 0.1), inset 0 0 1em rgba(0, 0, 0, 0.1);
+		}
+
+		#startStopBtn.running {
+			background-color: #FF3030;
+			border-color: #FF6060;
+			color: #FFFFFF;
+		}
+
+		#startStopBtn:before {
+			content: "Start";
+		}
+
+		#startStopBtn.running:before {
+			content: "Abort";
+		}
+
+		#serverArea {
+			margin-top: 1em;
+		}
+
+		#server {
+			font-size: 1em;
+			padding: 0.2em;
+		}
+
+		#test {
+			margin-top: 2em;
+			margin-bottom: 12em;
+		}
+
+		div.testArea {
+			display: inline-block;
+			width: 16em;
+			height: 12.5em;
+			position: relative;
+			box-sizing: border-box;
+		}
+
+		div.testArea2 {
+			display: inline-block;
+			width: 14em;
+			height: 7em;
+			position: relative;
+			box-sizing: border-box;
+			text-align: center;
+		}
+
+		div.testArea div.testName {
+			position: absolute;
+			top: 0.1em;
+			left: 0;
+			width: 100%;
+			font-size: 1.4em;
+			z-index: 9;
+		}
+
+		div.testArea2 div.testName {
+			display: block;
+			text-align: center;
+			font-size: 1.4em;
+		}
+
+		div.testArea div.meterText {
+			position: absolute;
+			bottom: 1.55em;
+			left: 0;
+			width: 100%;
+			font-size: 2.5em;
+			z-index: 9;
+		}
+
+		div.testArea2 div.meterText {
+			display: inline-block;
+			font-size: 2.5em;
+		}
+
+		div.meterText:empty:before {
+			content: "0.00";
+		}
+
+		div.testArea div.unit {
+			position: absolute;
+			bottom: 2em;
+			left: 0;
+			width: 100%;
+			z-index: 9;
+		}
+
+		div.testArea2 div.unit {
+			display: inline-block;
+		}
+
+		div.testArea canvas {
+			position: absolute;
+			top: 0;
+			left: 0;
+			width: 100%;
+			height: 100%;
+			z-index: 1;
+		}
+
+		div.testGroup {
+			display: block;
+			margin: 0 auto;
+		}
+
+		#shareArea {
+			width: 95%;
+			max-width: 40em;
+			margin: 0 auto;
+			margin-top: 2em;
+		}
+
+		#shareArea>* {
+			display: block;
+			width: 100%;
+			height: auto;
+			margin: 0.25em 0;
+		}
+
+		#privacyPolicy {
+			position: fixed;
+			top: 2em;
+			bottom: 2em;
+			left: 2em;
+			right: 2em;
+			overflow-y: auto;
+			width: auto;
+			height: auto;
+			box-shadow: 0 0 3em 1em #000000;
+			z-index: 999999;
+			text-align: left;
+			background-color: #FFFFFF;
+			padding: 1em;
+		}
+
+		a.privacy {
+			text-align: center;
+			font-size: 0.8em;
+			color: #808080;
+			padding: 0 3em;
+		}
+
+		div.closePrivacyPolicy {
+			width: 100%;
+			text-align: center;
+		}
+
+		div.closePrivacyPolicy a.privacy {
+			padding: 1em 3em;
+		}
+
+		@media all and (max-width:40em) {
+			body {
+				font-size: 0.8em;
+			}
+		}
+
+		div.visible {
+			animation: fadeIn 0.4s;
+			display: block;
+		}
+
+		div.hidden {
+			animation: fadeOut 0.4s;
+			display: none;
+		}
+
+		@keyframes fadeIn {
+			0% {
+				opacity: 0;
+			}
+
+			100% {
+				opacity: 1;
+			}
+		}
+
+		@keyframes fadeOut {
+			0% {
+				display: block;
+				opacity: 1;
+			}
+
+			100% {
+				display: block;
+				opacity: 0;
+			}
+		}
+
+		@media all and (prefers-color-scheme: dark) {
+
+			html,
+			body,
+			#loading {
+				background: #202020;
+				color: #F4F4F4;
+				color-scheme: dark;
+			}
+
+			h1 {
+				color: #E0E0E0;
+			}
+
+			a {
+				color: #9090FF;
+			}
+
+			#privacyPolicy {
+				background: #000000;
+			}
+
+			#resultsImg {
+				filter: invert(1);
+			}
+		}
+	</style>
+	<title>LibreSpeed</title>
+</head>
+
+<body onload="initServers()">
+	<h1>LibreSpeed</h1>
+	<div id="loading" class="visible">
+		<p id="message"><span class="loadCircle"></span>Selecting a server...</p>
+	</div>
+	<div id="testWrapper" class="hidden">
+		<button id="startStopBtn" onclick="startStop()" aria-label="Start"></button><br />
+		<a class="privacy" href="#" onclick="I('privacyPolicy').style.display=''">Privacy</a>
+		<div id="serverArea">
+			Server: <select id="server" onchange="s.setSelectedServer(SPEEDTEST_SERVERS[this.value])"></select>
+		</div>
+		<div id="test">
+			<div class="testGroup">
+				<div class="testArea2">
+					<div class="testName">Ping</div>
+					<div id="pingText" class="meterText" style="color:#AA6060"></div>
+					<div class="unit">ms</div>
+				</div>
+				<div class="testArea2">
+					<div class="testName">Jitter</div>
+					<div id="jitText" class="meterText" style="color:#AA6060"></div>
+					<div class="unit">ms</div>
+				</div>
+			</div>
+			<div class="testGroup">
+				<div class="testArea">
+					<div class="testName">Download</div>
+					<canvas id="dlMeter" class="meter"></canvas>
+					<div id="dlText" class="meterText"></div>
+					<div class="unit">Mbit/s</div>
+				</div>
+				<div class="testArea">
+					<div class="testName">Upload</div>
+					<canvas id="ulMeter" class="meter"></canvas>
+					<div id="ulText" class="meterText"></div>
+					<div class="unit">Mbit/s</div>
+				</div>
+			</div>
+			<div id="ipArea">
+				<span id="ip"></span>
+			</div>
+			<div id="shareArea" style="display:none">
+				<h3>Share results</h3>
+				<p>Test ID: <span id="testId"></span></p>
+				<input type="text" value="" id="resultsURL" readonly="readonly"
+					onclick="this.select();this.focus();this.select();document.execCommand('copy');alert('Link copied')" />
+				<img src="" id="resultsImg" />
+			</div>
+		</div>
+		<a href="index.html?design=new">Try the modern design</a><br>
+		<a href="stability.html">Stability Test</a> | <a href="https://github.com/librespeed/speedtest">Source code</a>
+	</div>
+	<div id="privacyPolicy" style="display:none">
+		<h2>Privacy Policy</h2>
+		<p>This HTML5 speed test server is configured with telemetry enabled.</p>
+		<h4>What data we collect</h4>
+		<p>
+			At the end of the test, the following data is collected and stored:
+		<ul>
+			<li>Test ID</li>
+			<li>Time of testing</li>
+			<li>Test results (download and upload speed, ping and jitter)</li>
+			<li>IP address</li>
+			<li>ISP information</li>
+			<li>Approximate location (inferred from IP address, not GPS)</li>
+			<li>User agent and browser locale</li>
+			<li>Test log (contains no personal information)</li>
+		</ul>
+		</p>
+		<h4>How we use the data</h4>
+		<p>
+			Data collected through this service is used to:
+		<ul>
+			<li>Allow sharing of test results (sharable image for forums, etc.)</li>
+			<li>To improve the service offered to you (for instance, to detect problems on our side)</li>
+		</ul>
+		No personal information is disclosed to third parties.
+		</p>
+		<h4>Your consent</h4>
+		<p>
+			By starting the test, you consent to the terms of this privacy policy.
+		</p>
+		<h4>Data removal</h4>
+		<p>
+			If you want to have your information deleted, you need to provide either the ID of the test or your IP
+			address. This is the only way to identify your data, without this information we won't be able to comply
+			with your request.<br /><br />
+			Contact this email address for all deletion requests: 
+			<a href="mailto:PUT@YOUR_EMAIL.HERE">TO BE FILLED BY DEVELOPER</a>.
+		</p>
+		<br /><br />
+		<div class="closePrivacyPolicy">
+			<a class="privacy" href="#" onclick="I('privacyPolicy').style.display='none'">Close</a>
+		</div>
+		<br />
+	</div>
+</body>
+
+</html>

+ 152 - 0
index-modern.html

@@ -0,0 +1,152 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
+  <meta name="description"
+    content="Free and Open Source Speedtest. Run it right now in your browser, or self-host on a PHP, Golang, Rust or Node server. License: LGPL." />
+  <link rel="shortcut icon" href="images/favicon.svg" />
+  <script type="text/javascript" src="speedtest.js"></script>
+  <script type="text/javascript">
+    // Set this to a different URL to load the server list from another location.
+    var SPEEDTEST_SERVERS = "server-list.json";
+  </script>
+  <script type="text/javascript" src="javascript/index.js"></script>
+  <link rel="stylesheet" type="text/css" href="styling/index.css" />
+  <title>LibreSpeed - Free and Open Source Speedtest</title>
+</head>
+
+<body>
+  <header>
+    <img src="images/logo.svg" alt="LibreSpeed" />
+  </header>
+  <main>
+    <h1>Free and Open Source Speedtest.</h1>
+    <p class="tagline">No Flash, No Java, No Websockets, No Bullsh*t</p>
+
+    <div class="server-selector">
+      <div class="chosen">
+        <div class="chevron">
+          <img src="images/chevron.svg" alt="select..." />
+        </div>
+        <p>current server</p>
+        <h2 id="selected-server">searching nearest server...</h2>
+      </div>
+      <ul class="servers"></ul>
+      <p class="sponsor" id="sponsor">&nbsp;</p>
+    </div>
+
+    <p id="privacy-warning" class="hidden">
+      by clicking the start button you agree to our privacy policy<br />
+      <a href="#" id="choose-privacy">or choose your privacy options</a>
+    </p>
+    <button class="disabled" id="start-button"></button>
+
+    <div class="gauge-layout">
+      <div class="ping hidden">
+        <span class="label">Ping</span>:&nbsp;
+        <span class="value" id="ping">00</span>ms
+      </div>
+
+      <div class="gauge download" id="download-gauge">
+        <div class="progress"></div>
+        <div class="speed"></div>
+        <h1><span id="download-speed">00</span> Mbps</h1>
+        <h2>Download</h2>
+      </div>
+
+      <div class="gauge upload" id="upload-gauge">
+        <div class="progress"></div>
+        <div class="speed"></div>
+        <h1><span id="upload-speed">00</span> Mbps</h1>
+        <h2>Upload</h2>
+      </div>
+
+      <div class="jitter hidden">
+        <span class="label">Jitter</span>:&nbsp;
+        <span class="value" id="jitter">00</span>ms
+      </div>
+    </div>
+
+    <button class="small inverted hidden" id="share-results">
+      Share results
+    </button>
+  </main>
+  <footer>
+    <p class="source">
+      <a href="stability.html">stability test</a> | <a href="https://github.com/librespeed/speedtest">source code</a>
+    </p>
+  </footer>
+
+  <dialog id="share">
+    <div class="close-dialog">
+      <img src="images/close-button.svg" alt="Close" />
+    </div>
+    <img id="results" src="" alt="Test results in graphical form" />
+    <button id="copy-link">Copy link</button>
+  </dialog>
+
+  <dialog id="privacy">
+    <div class="close-dialog">
+      <img src="images/close-button.svg" alt="Close" />
+    </div>
+    <section>
+      <h1>Privacy Policy</h1>
+      <p>
+        This HTML5 speed test server is configured with telemetry enabled.
+      </p>
+
+      <h2>What data we collect</h2>
+      <p>
+        At the end of the test, the following data is collected and stored:
+      </p>
+
+      <ul>
+        <li>Test ID</li>
+        <li>Time of testing</li>
+        <li>Test results (download and upload speed, ping and jitter)</li>
+        <li>IP address</li>
+        <li>ISP information</li>
+        <li>Approximate location (inferred from IP address, not GPS)</li>
+        <li>User agent and browser locale</li>
+        <li>Test log (contains no personal information)</li>
+      </ul>
+
+      <h2>How we use the data</h2>
+      <p>Data collected through this service is used to:</p>
+
+      <ul>
+        <li>
+          Allow sharing of test results (sharable image for forums, etc.)
+        </li>
+        <li>
+          To improve the service offered to you (for instance, to detect
+          problems on our side)
+        </li>
+      </ul>
+
+      <p>No personal information is disclosed to third parties.</p>
+
+      <h2>Your consent</h2>
+      <p>
+        By starting the test, you consent to the terms of this privacy policy.
+      </p>
+
+      <h2>Data removal</h2>
+      <p>
+        If you want to have your information deleted, you need to provide
+        either the ID of the test or your IP address. This is the only way to
+        identify your data, without this information we won't be able to
+        comply with your request.
+      </p>
+      <p>
+        Contact this email address for all deletion requests:
+        <a href="mailto:PUT@YOUR_EMAIL.HERE">TO BE FILLED BY DEVELOPER</a>.
+      </p>
+    </section>
+    <button id="close-privacy">Close</button>
+  </dialog>
+</body>
+
+</html>

+ 11 - 513
index.html

@@ -1,518 +1,16 @@
 <!DOCTYPE html>
 <html>
-<head>
-<link rel="shortcut icon" href="favicon.ico">
-<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no" />
-<meta charset="UTF-8" />
-<script type="text/javascript" src="speedtest.js"></script>
-<script type="text/javascript">
-function I(i){return document.getElementById(i);}
-
-//LIST OF TEST SERVERS. Leave empty if you're doing a standalone installation. See documentation for details
-var SPEEDTEST_SERVERS=[
-	/*{	//this server doesn't actually exist, remove it
-		name:"Example Server 1", //user friendly name for the server
-		server:"//test1.mydomain.com/", //URL to the server. // at the beginning will be replaced with http:// or https:// automatically
-		dlURL:"backend/garbage.php",  //path to download test on this server (garbage.php or replacement)
-		ulURL:"backend/empty.php",  //path to upload test on this server (empty.php or replacement)
-		pingURL:"backend/empty.php",  //path to ping/jitter test on this server (empty.php or replacement)
-		getIpURL:"backend/getIP.php"  //path to getIP on this server (getIP.php or replacement)
-	},
-	{	//this server doesn't actually exist, remove it
-		name:"Example Server 2", //user friendly name for the server
-		server:"//test2.example.com/", //URL to the server. // at the beginning will be replaced with http:// or https:// automatically
-		dlURL:"garbage.php",  //path to download test on this server (garbage.php or replacement)
-		ulURL:"empty.php",  //path to upload test on this server (empty.php or replacement)
-		pingURL:"empty.php",  //path to ping/jitter test on this server (empty.php or replacement)
-		getIpURL:"getIP.php"  //path to getIP on this server (getIP.php or replacement)
-	}*/
-	//add other servers here, comma separated
-];
-
-//INITIALIZE SPEEDTEST
-var s=new Speedtest(); //create speed test object
-s.setParameter("telemetry_level","basic"); //enable basic telemetry (for results sharing)
-
-//SERVER AUTO SELECTION
-function initServers(){
-	if(SPEEDTEST_SERVERS.length==0){ //standalone installation
-		//just make the UI visible
-		I("loading").className="hidden";
-		I("serverArea").style.display="none";
-		I("testWrapper").className="visible";
-		initUI();
-	}else{ //multiple servers
-		var noServersAvailable=function(){
-			I("message").innerHTML="No servers available";
-		}
-		var runServerSelect=function(){
-			s.selectServer(function(server){
-				if(server!=null){ //at least 1 server is available
-					I("loading").className="hidden"; //hide loading message
-					//populate server list for manual selection
-					for(var i=0;i<SPEEDTEST_SERVERS.length;i++){
-						if(SPEEDTEST_SERVERS[i].pingT==-1) continue;
-						var option=document.createElement("option");
-						option.value=i;
-						option.textContent=SPEEDTEST_SERVERS[i].name;
-						if(SPEEDTEST_SERVERS[i]===server) option.selected=true;
-						I("server").appendChild(option);
-					}
-					//show test UI
-					I("testWrapper").className="visible";
-					initUI();
-				}else{ //no servers are available, the test cannot proceed
-					noServersAvailable();
-				}
-			});
-		}
-		if(typeof SPEEDTEST_SERVERS === "string"){
-			//need to fetch list of servers from specified URL
-			s.loadServerList(SPEEDTEST_SERVERS,function(servers){
-				if(servers==null){ //failed to load server list
-					noServersAvailable();
-				}else{ //server list loaded
-					SPEEDTEST_SERVERS=servers;
-					runServerSelect();
-				}
-			});
-		}else{
-			//hardcoded server list
-			s.addTestPoints(SPEEDTEST_SERVERS);
-			runServerSelect();
-		}
-	}
-}
-
-var meterBk=/Trident.*rv:(\d+\.\d+)/i.test(navigator.userAgent)?"#EAEAEA":"#80808040";
-var dlColor="#6060AA",
-	ulColor="#616161";
-var progColor=meterBk;
 
-//CODE FOR GAUGES
-function drawMeter(c,amount,bk,fg,progress,prog){
-	var ctx=c.getContext("2d");
-	var dp=window.devicePixelRatio||1;
-	var cw=c.clientWidth*dp, ch=c.clientHeight*dp;
-	var sizScale=ch*0.0055;
-	if(c.width==cw&&c.height==ch){
-		ctx.clearRect(0,0,cw,ch);
-	}else{
-		c.width=cw;
-		c.height=ch;
-	}
-	ctx.beginPath();
-	ctx.strokeStyle=bk;
-	ctx.lineWidth=12*sizScale;
-	ctx.arc(c.width/2,c.height-58*sizScale,c.height/1.8-ctx.lineWidth,-Math.PI*1.1,Math.PI*0.1);
-	ctx.stroke();
-	ctx.beginPath();
-	ctx.strokeStyle=fg;
-	ctx.lineWidth=12*sizScale;
-	ctx.arc(c.width/2,c.height-58*sizScale,c.height/1.8-ctx.lineWidth,-Math.PI*1.1,amount*Math.PI*1.2-Math.PI*1.1);
-	ctx.stroke();
-	if(typeof progress !== "undefined"){
-		ctx.fillStyle=prog;
-		ctx.fillRect(c.width*0.3,c.height-16*sizScale,c.width*0.4*progress,4*sizScale);
-	}
-}
-function mbpsToAmount(s){
-	return 1-(1/(Math.pow(1.3,Math.sqrt(s))));
-}
-function format(d){
-    d=Number(d);
-    if(d<10) return d.toFixed(2);
-    if(d<100) return d.toFixed(1);
-    return d.toFixed(0);
-}
-
-//UI CODE
-var uiData=null;
-function startStop(){
-    if(s.getState()==3){
-		//speed test is running, abort
-		s.abort();
-		data=null;
-		I("startStopBtn").className="";
-		I("server").disabled=false;
-		initUI();
-	}else{
-		//test is not running, begin
-		I("startStopBtn").className="running";
-		I("shareArea").style.display="none";
-		I("server").disabled=true;
-		s.onupdate=function(data){
-            uiData=data;
-		};
-		s.onend=function(aborted){
-            I("startStopBtn").className="";
-            I("server").disabled=false;
-            updateUI(true);
-            if(!aborted){
-                //if testId is present, show sharing panel, otherwise do nothing
-                try{
-                    var testId=uiData.testId;
-                    if(testId!=null){
-                        var shareURL=window.location.href.substring(0,window.location.href.lastIndexOf("/"))+"/results/?id="+testId;
-                        I("resultsImg").src=shareURL;
-                        I("resultsImg").alt="Download: "+uiData.dlStatus+" Mbps, Upload: "+uiData.ulStatus+" Mbps, Ping: "+uiData.pingStatus+" ms, Jitter: "+uiData.jitterStatus+" ms";
-                        I("resultsURL").value=shareURL;
-                        I("testId").innerHTML=testId;
-                        I("shareArea").style.display="";
-                    }
-                }catch(e){}
-            }
-		};
-		s.start();
-	}
-}
-//this function reads the data sent back by the test and updates the UI
-function updateUI(forced){
-	if(!forced&&s.getState()!=3) return;
-	if(uiData==null) return;
-	var status=uiData.testState;
-	I("ip").textContent=uiData.clientIp;
-	I("dlText").textContent=(status==1&&uiData.dlStatus==0)?"...":format(uiData.dlStatus);
-	drawMeter(I("dlMeter"),mbpsToAmount(Number(uiData.dlStatus*(status==1?oscillate():1))),meterBk,dlColor,Number(uiData.dlProgress),progColor);
-	I("ulText").textContent=(status==3&&uiData.ulStatus==0)?"...":format(uiData.ulStatus);
-	drawMeter(I("ulMeter"),mbpsToAmount(Number(uiData.ulStatus*(status==3?oscillate():1))),meterBk,ulColor,Number(uiData.ulProgress),progColor);
-	I("pingText").textContent=format(uiData.pingStatus);
-	I("jitText").textContent=format(uiData.jitterStatus);
-}
-function oscillate(){
-	return 1+0.02*Math.sin(Date.now()/100);
-}
-//update the UI every frame
-window.requestAnimationFrame=window.requestAnimationFrame||window.webkitRequestAnimationFrame||window.mozRequestAnimationFrame||window.msRequestAnimationFrame||(function(callback,element){setTimeout(callback,1000/60);});
-function frame(){
-	requestAnimationFrame(frame);
-	updateUI();
-}
-frame(); //start frame loop
-//function to (re)initialize UI
-function initUI(){
-	drawMeter(I("dlMeter"),0,meterBk,dlColor,0);
-	drawMeter(I("ulMeter"),0,meterBk,ulColor,0);
-	I("dlText").textContent="";
-	I("ulText").textContent="";
-	I("pingText").textContent="";
-	I("jitText").textContent="";
-	I("ip").textContent="";
-}
-</script>
-<style type="text/css">
-	html,body{
-		border:none; padding:0; margin:0;
-		background:#FFFFFF;
-		color:#202020;
-	}
-	body{
-		text-align:center;
-		font-family:"Roboto",sans-serif;
-	}
-	h1{
-		color:#404040;
-	}
-	#loading{
-		background-color:#FFFFFF;
-		color:#404040;
-		text-align:center;
-	}
-	span.loadCircle{
-		display:inline-block;
-		width:2em;
-		height:2em;
-		vertical-align:middle;
-		background:url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAAAP1BMVEUAAAB2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZyFzwnAAAAFHRSTlMAEvRFvX406baecwbf0casimhSHyiwmqgAAADpSURBVHja7dbJbQMxAENRahnN5lkc//5rDRAkDeRgHszXgACJoKiIiIiIiIiIiIiIiIiIiIj4HHspsrpAVhdVVguzrA4OWc10WcEqpwKbnBo0OU1Q5NSpsoJFTgOecrrdEag85DRgktNqfoEdTjnd7hrEHMEJvmRUYJbTYk5Agy6nau6Abp5Cm7mDBtRdPi9gyKdU7w4p1fsLvyqs8hl4z9/w3n/Hmr9WoQ65lAU4d7lMYOz//QboRR5jBZibLMZdAR6O/Vfa1PlxNr3XdS3HzK/HVPRu/KnLs8iAOh993VpRRERERMT/fAN60wwWaVyWwAAAAABJRU5ErkJggg==');
-		background-size:2em 2em;
-		margin-right:0.5em;
-		animation: spin 0.6s linear infinite;
-	}
-	@keyframes spin{
-		0%{transform:rotate(0deg);}
-		100%{transform:rotate(359deg);}
-	}
-	#startStopBtn{
-		display:inline-block;
-		margin:0 auto;
-		color:#6060AA;
-		background-color:rgba(0,0,0,0);
-		border:0.15em solid #6060FF;
-		border-radius:0.3em;
-		transition:all 0.3s;
-		box-sizing:border-box;
-		width:8em; height:3em;
-		line-height:2.7em;
-		cursor:pointer;
-		box-shadow: 0 0 0 rgba(0,0,0,0.1), inset 0 0 0 rgba(0,0,0,0.1);
-	}
-	#startStopBtn:hover{
-		box-shadow: 0 0 2em rgba(0,0,0,0.1), inset 0 0 1em rgba(0,0,0,0.1);
-	}
-	#startStopBtn.running{
-		background-color:#FF3030;
-		border-color:#FF6060;
-		color:#FFFFFF;
-	}
-	#startStopBtn:before{
-		content:"Start";
-	}
-	#startStopBtn.running:before{
-		content:"Abort";
-	}
-	#serverArea{
-		margin-top:1em;
-	}
-	#server{
-		font-size:1em;
-		padding:0.2em;
-	}
-	#test{
-		margin-top:2em;
-		margin-bottom:12em;
-	}
-	div.testArea{
-		display:inline-block;
-		width:16em;
-		height:12.5em;
-		position:relative;
-		box-sizing:border-box;
-	}
-	div.testArea2{
-		display:inline-block;
-		width:14em;
-		height:7em;
-		position:relative;
-		box-sizing:border-box;
-		text-align:center;
-	}
-	div.testArea div.testName{
-		position:absolute;
-		top:0.1em; left:0;
-		width:100%;
-		font-size:1.4em;
-		z-index:9;
-	}
-	div.testArea2 div.testName{
-        display:block;
-        text-align:center;
-        font-size:1.4em;
-	}
-	div.testArea div.meterText{
-		position:absolute;
-		bottom:1.55em; left:0;
-		width:100%;
-		font-size:2.5em;
-		z-index:9;
-	}
-	div.testArea2 div.meterText{
-        display:inline-block;
-        font-size:2.5em;
-	}
-	div.meterText:empty:before{
-		content:"0.00";
-	}
-	div.testArea div.unit{
-		position:absolute;
-		bottom:2em; left:0;
-		width:100%;
-		z-index:9;
-	}
-	div.testArea2 div.unit{
-		display:inline-block;
-	}
-	div.testArea canvas{
-		position:absolute;
-		top:0; left:0; width:100%; height:100%;
-		z-index:1;
-	}
-	div.testGroup{
-		display:block;
-        margin: 0 auto;
-	}
-	#shareArea{
-		width:95%;
-		max-width:40em;
-		margin:0 auto;
-		margin-top:2em;
-	}
-	#shareArea > *{
-		display:block;
-		width:100%;
-		height:auto;
-		margin: 0.25em 0;
-	}
-	#privacyPolicy{
-        position:fixed;
-        top:2em;
-        bottom:2em;
-        left:2em;
-        right:2em;
-        overflow-y:auto;
-        width:auto;
-        height:auto;
-        box-shadow:0 0 3em 1em #000000;
-        z-index:999999;
-        text-align:left;
-        background-color:#FFFFFF;
-        padding:1em;
-	}
-	a.privacy{
-        text-align:center;
-        font-size:0.8em;
-        color:#808080;
-        padding: 0 3em;
-	}
-    div.closePrivacyPolicy {
-        width: 100%;
-        text-align: center;
-    }
-    div.closePrivacyPolicy a.privacy {
-        padding: 1em 3em;
-    }
-	@media all and (max-width:40em){
-		body{
-			font-size:0.8em;
-		}
-	}
-	div.visible{
-		animation: fadeIn 0.4s;
-		display:block;
-	}
-	div.hidden{
-		animation: fadeOut 0.4s;
-		display:none;
-	}
-	@keyframes fadeIn{
-		0%{
-			opacity:0;
-		}
-		100%{
-			opacity:1;
-		}
-	}
-	@keyframes fadeOut{
-		0%{
-			display:block;
-			opacity:1;
-		}
-		100%{
-			display:block;
-			opacity:0;
-		}
-	}
-	@media all and (prefers-color-scheme: dark){
-		html,body,#loading{
-			background:#202020;
-			color:#F4F4F4;
-			color-scheme:dark;
-		}
-		h1{
-			color:#E0E0E0;
-		}
-		a{
-			color:#9090FF;
-		}
-		#privacyPolicy{
-			background:#000000;
-		}
-		#resultsImg{
-			filter: invert(1);
-		}
-	}
-</style>
-<title>LibreSpeed</title>
+<head>
+    <link rel="shortcut icon" href="favicon.ico">
+    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no" />
+    <meta charset="UTF-8" />
+    <script type="text/javascript" src="design-switch.js"></script>
+    <title>LibreSpeed</title>
 </head>
-<body onload="initServers()">
-<h1>LibreSpeed</h1>
-<div id="loading" class="visible">
-	<p id="message"><span class="loadCircle"></span>Selecting a server...</p>
-</div>
-<div id="testWrapper" class="hidden">
-	<div id="startStopBtn" onclick="startStop()"></div><br/>
-	<a class="privacy" href="#" onclick="I('privacyPolicy').style.display=''">Privacy</a>
-	<div id="serverArea">
-		Server: <select id="server" onchange="s.setSelectedServer(SPEEDTEST_SERVERS[this.value])"></select>
-	</div>
-	<div id="test">
-		<div class="testGroup">
-            <div class="testArea2">
-				<div class="testName">Ping</div>
-				<div id="pingText" class="meterText" style="color:#AA6060"></div>
-				<div class="unit">ms</div>
-			</div>
-			<div class="testArea2">
-				<div class="testName">Jitter</div>
-				<div id="jitText" class="meterText" style="color:#AA6060"></div>
-				<div class="unit">ms</div>
-			</div>
-		</div>
-		<div class="testGroup">
-			<div class="testArea">
-				<div class="testName">Download</div>
-				<canvas id="dlMeter" class="meter"></canvas>
-				<div id="dlText" class="meterText"></div>
-				<div class="unit">Mbit/s</div>
-			</div>
-			<div class="testArea">
-				<div class="testName">Upload</div>
-				<canvas id="ulMeter" class="meter"></canvas>
-				<div id="ulText" class="meterText"></div>
-				<div class="unit">Mbit/s</div>
-			</div>
-		</div>
-		<div id="ipArea">
-			<span id="ip"></span>
-		</div>
-		<div id="shareArea" style="display:none">
-			<h3>Share results</h3>
-			<p>Test ID: <span id="testId"></span></p>
-			<input type="text" value="" id="resultsURL" readonly="readonly" onclick="this.select();this.focus();this.select();document.execCommand('copy');alert('Link copied')"/>
-			<img src="" id="resultsImg" />
-		</div>
-	</div>
-	<a href="https://github.com/librespeed/speedtest">Source code</a>
-</div>
-<div id="privacyPolicy" style="display:none">
-    <h2>Privacy Policy</h2>
-    <p>This HTML5 speed test server is configured with telemetry enabled.</p>
-    <h4>What data we collect</h4>
-    <p>
-        At the end of the test, the following data is collected and stored:
-        <ul>
-            <li>Test ID</li>
-            <li>Time of testing</li>
-            <li>Test results (download and upload speed, ping and jitter)</li>
-            <li>IP address</li>
-            <li>ISP information</li>
-            <li>Approximate location (inferred from IP address, not GPS)</li>
-            <li>User agent and browser locale</li>
-            <li>Test log (contains no personal information)</li>
-        </ul>
-    </p>
-    <h4>How we use the data</h4>
-    <p>
-        Data collected through this service is used to:
-        <ul>
-            <li>Allow sharing of test results (sharable image for forums, etc.)</li>
-            <li>To improve the service offered to you (for instance, to detect problems on our side)</li>
-        </ul>
-        No personal information is disclosed to third parties.
-    </p>
-    <h4>Your consent</h4>
-    <p>
-        By starting the test, you consent to the terms of this privacy policy.
-    </p>
-    <h4>Data removal</h4>
-    <p>
-        If you want to have your information deleted, you need to provide either the ID of the test or your IP address. This is the only way to identify your data, without this information we won't be able to comply with your request.<br/><br/>
-        Contact this email address for all deletion requests: <a href="mailto:PUT@YOUR_EMAIL.HERE">TO BE FILLED BY DEVELOPER</a>.
-    </p>
-    <br/><br/>
-    <div class="closePrivacyPolicy">
-        <a class="privacy" href="#" onclick="I('privacyPolicy').style.display='none'">Close</a>
-    </div>
-    <br/>
-</div>
+
+<body>
+    <p>Loading...</p>
 </body>
-</html>
+
+</html>

+ 4 - 1
package.json

@@ -1,10 +1,12 @@
 {
   "name": "librespeed-speedtest",
-  "version": "5.5.1",
+  "version": "6.1.0",
   "description": "LibreSpeed - A Free and Open Source speed test that you can host on your server(s)",
   "main": "speedtest.js",
   "scripts": {
     "test": "echo \"No automated tests configured yet\" && exit 0",
+    "test:e2e": "playwright test",
+    "test:e2e:headed": "playwright test --headed",
     "lint": "eslint speedtest.js speedtest_worker.js stability_worker.js",
     "lint:fix": "eslint --fix speedtest.js speedtest_worker.js stability_worker.js",
     "format": "prettier --write \"*.js\"",
@@ -37,6 +39,7 @@
   },
   "homepage": "https://github.com/librespeed/speedtest#readme",
   "devDependencies": {
+    "@playwright/test": "^1.55.0",
     "eslint": "^8.57.0",
     "prettier": "^3.1.1"
   },

+ 27 - 0
playwright.config.js

@@ -0,0 +1,27 @@
+const { defineConfig, devices } = require('@playwright/test');
+
+module.exports = defineConfig({
+  testDir: './tests/e2e',
+  fullyParallel: true,
+  forbidOnly: !!process.env.CI,
+  retries: process.env.CI ? 1 : 0,
+  workers: process.env.CI ? 2 : undefined,
+  timeout: 30_000,
+  expect: {
+    timeout: 7_500,
+  },
+  reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : [['list']],
+  use: {
+    trace: 'on-first-retry',
+    screenshot: 'only-on-failure',
+    video: 'off',
+  },
+  globalSetup: require.resolve('./tests/e2e/global-setup.js'),
+  globalTeardown: require.resolve('./tests/e2e/global-teardown.js'),
+  projects: [
+    {
+      name: 'chromium',
+      use: { ...devices['Desktop Chrome'] },
+    },
+  ],
+});

+ 6 - 0
results/telemetry_mssql.sql

@@ -1,3 +1,9 @@
+CREATE DATABASE librespeed;
+GO
+
+USE librespeed;
+GO
+
 SET ANSI_NULLS ON
 GO
 

+ 1 - 1
speedtest.js

@@ -49,7 +49,7 @@ function Speedtest() {
   this._settings = {}; //settings for the speed test worker
   this._state = 0; //0=adding settings, 1=adding servers, 2=server selection done, 3=test running, 4=done
   console.log(
-    "LibreSpeed by Federico Dossena v5.5.1 - https://github.com/librespeed/speedtest"
+    "LibreSpeed by Federico Dossena v6.1.0 - https://github.com/librespeed/speedtest"
   );
 }
 

+ 24 - 7
stability.html

@@ -385,7 +385,14 @@
       var CHART_PADDING_TOP = 15;
       var CHART_PADDING_BOTTOM = 30;
 
-      // Load server list dynamically (for Docker frontend/dual modes where servers.json exists)
+      function joinServerUrl(server, path) {
+        if (!server) return path;
+        if (!path) return server;
+        if (server.charAt(server.length - 1) === "/" || path.charAt(0) === "/") return server + path;
+        return server + "/" + path;
+      }
+
+      // Load server list dynamically (Docker exposes server-list.json; older setups may expose servers.json)
       function loadServers(callback) {
         var xhr = new XMLHttpRequest();
         xhr.onload = function () {
@@ -395,12 +402,22 @@
               SPEEDTEST_SERVERS = servers;
             }
           } catch (e) {}
-          callback();
+          if (SPEEDTEST_SERVERS.length > 0 || xhr._fallbackTried) callback();
+          else {
+            xhr._fallbackTried = true;
+            xhr.open("GET", "servers.json?r=" + Math.random());
+            xhr.send();
+          }
         };
         xhr.onerror = function () {
-          callback();
+          if (xhr._fallbackTried) callback();
+          else {
+            xhr._fallbackTried = true;
+            xhr.open("GET", "servers.json?r=" + Math.random());
+            xhr.send();
+          }
         };
-        xhr.open("GET", "servers.json?r=" + Math.random());
+        xhr.open("GET", "server-list.json?r=" + Math.random());
         try {
           xhr.timeout = 2000;
           xhr.ontimeout = xhr.onerror;
@@ -442,8 +459,8 @@
       }
 
       function pingServer(server, callback) {
-        var url =
-          server.server + server.pingURL + (server.pingURL.match(/\?/) ? "&" : "?") + "cors=true&r=" + Math.random();
+        var baseUrl = joinServerUrl(server.server, server.pingURL);
+        var url = baseUrl + (baseUrl.match(/\?/) ? "&" : "?") + "cors=true&r=" + Math.random();
         var xhr = new XMLHttpRequest();
         var t = new Date().getTime();
         xhr.onload = function () {
@@ -494,7 +511,7 @@
         };
 
         if (!externalTarget && selectedServer) {
-          workerSettings.url_ping = selectedServer.server + selectedServer.pingURL;
+          workerSettings.url_ping = joinServerUrl(selectedServer.server, selectedServer.pingURL);
           workerSettings.mpot = true;
         }
 

+ 129 - 0
tests/PLAYWRIGHT_MODES_PLAN.md

@@ -0,0 +1,129 @@
+# Playwright Plan: Test All Runtime and UI Modes
+
+## Objective
+Build a deterministic Playwright test suite that validates LibreSpeed behavior across all supported deployment modes and UI design modes, without asserting real network throughput values.
+
+## Current Status
+- Phase 1 is implemented and passing in Chromium.
+- Added regression coverage for the classic standalone "No servers available" issue path.
+- Docker image workflow is hard-gated by e2e (`build` depends on `e2e`).
+- Standalone Playwright workflow is manual-only to avoid duplicate e2e runs.
+
+## Modes to Cover
+
+### Docker runtime modes
+- `standalone`
+- `backend`
+- `frontend`
+- `dual`
+
+### UI design modes
+- Classic (`index-classic.html`)
+- Modern (`index-modern.html`)
+- Switcher behavior from `index.html`:
+  - default from `config.json` (`useNewDesign`)
+  - `?design=new` override
+  - `?design=old` override
+
+## Test Strategy
+
+### 1. Keep assertions deterministic
+Do not assert real bandwidth numbers. Focus on:
+- HTTP availability of expected files/endpoints
+- correct redirects/switching behavior
+- expected UI controls rendered
+- expected server-list loading behavior
+- ability to initiate/abort flow at UI level
+
+### 2. Separate test types
+- **Mode smoke tests** (fast, always-run): verify each runtime mode serves the right surfaces.
+- **UI mode tests**: verify classic/modern pages and switcher rules.
+- **Optional flow tests** (later): mock `Speedtest` in browser to simulate state changes and verify UI updates.
+
+### 3. Use Docker Compose as the environment contract
+Run Playwright against containers started with explicit `MODE` values to mirror production entrypoint behavior.
+
+## Proposed Test Matrix
+
+### A) `standalone`
+Expectations:
+- `GET /` responds and serves UI (classic by default unless overridden)
+- `GET /backend/empty.php`, `GET /backend/garbage.php`, `GET /backend/getIP.php` available
+- `GET /results/telemetry.php` reachable (even if telemetry disabled behavior differs)
+- `GET /index.html?design=new` resolves to modern page
+- `GET /index.html?design=old` resolves to classic page
+
+### B) `backend`
+Expectations:
+- backend endpoints return success (`/backend/empty.php`, `/backend/garbage.php`, `/backend/getIP.php`)
+- tests only assert local backend endpoint contracts in this mode
+
+### C) `frontend`
+Expectations:
+- UI entrypoint available
+- server list loads from `/servers.json` (or `SERVER_LIST_URL` if set)
+- backend test endpoints should not be treated as local testpoint contract in this mode
+- selecting server and pressing start does not crash UI shell
+
+### D) `dual`
+Expectations:
+- combines frontend + local backend availability
+- UI can load multi-server list
+- backend endpoints available locally
+
+## Playwright Architecture
+
+### Files
+- `playwright.config.js`
+- `tests/e2e/modes.spec.js` (runtime-mode smoke)
+- `tests/e2e/design-switch.spec.js` (classic/modern/switch overrides)
+- `tests/e2e/classic-standalone-regression.spec.js` (revert regression guard)
+- `tests/e2e/helpers/env.js` (base URLs + mode metadata)
+- `tests/e2e/helpers/ui.js` (shared selectors, start/abort helpers)
+
+### Environment boot
+- `docker compose -f tests/docker-compose-playwright.yml up -d --build`
+- dedicate one service per runtime mode on separate ports
+- for `frontend` and `dual`, mount a stable `servers.json`
+
+### Selector policy
+Use role/text selectors anchored on stable labels and IDs already in pages; avoid brittle CSS-path selectors.
+
+## Phased Rollout
+
+### Phase 1 (recommended first PR)
+- Add Playwright scaffolding and CI job
+- Add smoke coverage for 4 Docker runtime modes
+- Add design switch tests (`index.html`, `?design=new`, `?design=old`)
+- No full speed measurement assertions
+
+### Phase 2
+- Add deterministic UI flow tests with mocked Speedtest state updates
+- Validate button states (`Start` -> running -> abort/end)
+- Validate result widgets receive simulated values
+
+### Phase 3
+- Add telemetry-enabled scenario tests (`TELEMETRY=true`) for link visibility and stats exposure
+- Add negative tests (missing/invalid `servers.json` in frontend/dual)
+
+## Risks and Mitigations
+- Flaky speed measurements due to host/network variance
+  - Mitigation: avoid throughput assertions; use mocked state for UI behavior.
+- Divergence between local static run and Docker entrypoint behavior
+  - Mitigation: run all mode tests against Docker services.
+- Selector drift between classic and modern UIs
+  - Mitigation: maintain per-design helper selectors with minimal coupling.
+
+## CI Proposal
+- Docker workflow runs e2e first, then build/push only if e2e passes
+- Standalone Playwright workflow is `workflow_dispatch` only for manual branch runs
+- Keep a single automatic e2e path to avoid duplicate runs
+- Playwright retries: `1` in CI, `0` locally
+- Upload traces/screenshots on failure only
+- Browser scope for v1: Chromium only
+
+## Confirmed Decisions
+1. Browser scope for v1: Chromium only.
+2. Telemetry checks are deferred to Phase 3.
+3. `backend` mode tests assert backend endpoint contracts only.
+4. Automatic e2e gating lives in Docker workflow; standalone Playwright workflow is manual.

+ 82 - 0
tests/docker-compose-playwright.yml

@@ -0,0 +1,82 @@
+services:
+  backend-testpoint:
+    build:
+      context: ..
+      dockerfile: Dockerfile
+    environment:
+      - MODE=backend
+      - WEBPORT=8080
+
+  standalone:
+    build:
+      context: ..
+      dockerfile: Dockerfile
+    environment:
+      - MODE=standalone
+      - WEBPORT=8080
+      - USE_NEW_DESIGN=false
+    ports:
+      - "18180:8080"
+
+  standalone-new:
+    build:
+      context: ..
+      dockerfile: Dockerfile
+    environment:
+      - MODE=standalone
+      - WEBPORT=8080
+      - USE_NEW_DESIGN=true
+      - 'TITLE=Grüße "Tempo" ''Österreich'''
+      - 'TAGLINE=No "Flash", <No Java>, No Websockets & No Bullsh*t'
+    ports:
+      - "18185:8080"
+
+  standalone-apostrophe:
+    build:
+      context: ..
+      dockerfile: Dockerfile
+    environment:
+      - MODE=standalone
+      - WEBPORT=8080
+      - USE_NEW_DESIGN=true
+      - "TAGLINE=It'd rather be fast!"
+    ports:
+      - "18186:8080"
+
+  backend:
+    build:
+      context: ..
+      dockerfile: Dockerfile
+    environment:
+      - MODE=backend
+      - WEBPORT=8080
+    ports:
+      - "18181:8080"
+
+  frontend:
+    build:
+      context: ..
+      dockerfile: Dockerfile
+    depends_on:
+      - backend-testpoint
+    environment:
+      - MODE=frontend
+      - WEBPORT=8080
+    volumes:
+      - ./e2e/fixtures/servers-frontend.json:/servers.json:ro
+    ports:
+      - "18182:8080"
+
+  dual:
+    build:
+      context: ..
+      dockerfile: Dockerfile
+    depends_on:
+      - backend-testpoint
+    environment:
+      - MODE=dual
+      - WEBPORT=8080
+    volumes:
+      - ./e2e/fixtures/servers-dual.json:/servers.json:ro
+    ports:
+      - "18183:8080"

+ 16 - 0
tests/e2e/classic-standalone-regression.spec.js

@@ -0,0 +1,16 @@
+const { test, expect } = require('@playwright/test');
+const { baseUrls } = require('./helpers/env');
+const { classicStartButton } = require('./helpers/ui');
+
+test.describe('Classic standalone regression coverage', () => {
+  test('standalone classic does not get stuck on "No servers available"', async ({ page }) => {
+    await page.goto(`${baseUrls.standalone}/index-classic.html`);
+
+    // In standalone mode, classic UI should show the test wrapper directly.
+    await expect(page.locator('#testWrapper')).toHaveClass(/visible/, { timeout: 10_000 });
+    await expect(page.locator('#loading')).toHaveClass(/hidden/, { timeout: 10_000 });
+
+    await expect(page.locator('#message')).not.toContainText(/No servers available/i);
+    await expect(classicStartButton(page)).toBeVisible();
+  });
+});

+ 29 - 0
tests/e2e/design-switch.spec.js

@@ -0,0 +1,29 @@
+const { test, expect } = require('@playwright/test');
+const { baseUrls } = require('./helpers/env');
+const { classicStartButton, modernStartButton } = require('./helpers/ui');
+
+test.describe('Design switch behavior', () => {
+  test('index.html defaults to classic when useNewDesign=false', async ({ page }) => {
+    await page.goto(`${baseUrls.standalone}/index.html`);
+    await expect(page).toHaveURL(/index-classic\.html/);
+    await expect(classicStartButton(page)).toBeVisible();
+  });
+
+  test('index.html defaults to modern when useNewDesign=true', async ({ page }) => {
+    await page.goto(`${baseUrls.standaloneNew}/index.html`);
+    await expect(page).toHaveURL(/index-modern\.html/);
+    await expect(modernStartButton(page)).toBeVisible();
+  });
+
+  test('query override design=new forces modern', async ({ page }) => {
+    await page.goto(`${baseUrls.standalone}/index.html?design=new`);
+    await expect(page).toHaveURL(/index-modern\.html\?design=new/);
+    await expect(modernStartButton(page)).toBeVisible();
+  });
+
+  test('query override design=old forces classic', async ({ page }) => {
+    await page.goto(`${baseUrls.standaloneNew}/index.html?design=old`);
+    await expect(page).toHaveURL(/index-classic\.html\?design=old/);
+    await expect(classicStartButton(page)).toBeVisible();
+  });
+});

+ 20 - 0
tests/e2e/fixtures/servers-dual.json

@@ -0,0 +1,20 @@
+[
+  {
+    "name": "Local dual backend",
+    "server": "/",
+    "dlURL": "backend/garbage.php",
+    "ulURL": "backend/empty.php",
+    "pingURL": "backend/empty.php",
+    "getIpURL": "backend/getIP.php",
+    "id": 1
+  },
+  {
+    "name": "External backend testpoint",
+    "server": "http://backend-testpoint/",
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "id": 2
+  }
+]

+ 11 - 0
tests/e2e/fixtures/servers-frontend.json

@@ -0,0 +1,11 @@
+[
+  {
+    "name": "Backend testpoint",
+    "server": "http://backend-testpoint/",
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "id": 1
+  }
+]

+ 39 - 0
tests/e2e/global-setup.js

@@ -0,0 +1,39 @@
+const { execSync } = require('node:child_process');
+
+const COMPOSE_FILE = 'tests/docker-compose-playwright.yml';
+
+async function waitForReady(name, url, timeoutMs) {
+  const start = Date.now();
+  while (Date.now() - start < timeoutMs) {
+    try {
+      const response = await fetch(url, { redirect: 'manual' });
+      if (response.ok) {
+        return;
+      }
+    } catch {
+      // Retry until timeout.
+    }
+    await new Promise((resolve) => setTimeout(resolve, 1000));
+  }
+  throw new Error(`Timed out waiting for ${name} at ${url}`);
+}
+
+module.exports = async () => {
+  try {
+    execSync(`docker compose -f ${COMPOSE_FILE} up -d --build`, {
+      stdio: 'inherit',
+    });
+  } catch (error) {
+    throw new Error(
+      `Failed to start Docker test stack from ${COMPOSE_FILE}. ` +
+        `Ensure Docker is running. Original error: ${error.message}`
+    );
+  }
+
+  const timeoutMs = 180_000;
+  await waitForReady('standalone', 'http://127.0.0.1:18180/index.html', timeoutMs);
+  await waitForReady('standalone-new', 'http://127.0.0.1:18185/index.html', timeoutMs);
+  await waitForReady('backend', 'http://127.0.0.1:18181/empty.php', timeoutMs);
+  await waitForReady('frontend', 'http://127.0.0.1:18182/index-modern.html', timeoutMs);
+  await waitForReady('dual', 'http://127.0.0.1:18183/index-modern.html', timeoutMs);
+};

+ 9 - 0
tests/e2e/global-teardown.js

@@ -0,0 +1,9 @@
+const { execSync } = require('node:child_process');
+
+const COMPOSE_FILE = 'tests/docker-compose-playwright.yml';
+
+module.exports = async () => {
+  execSync(`docker compose -f ${COMPOSE_FILE} down --remove-orphans`, {
+    stdio: 'inherit',
+  });
+};

+ 12 - 0
tests/e2e/helpers/env.js

@@ -0,0 +1,12 @@
+const baseUrls = {
+  standalone: 'http://127.0.0.1:18180',
+  backend: 'http://127.0.0.1:18181',
+  frontend: 'http://127.0.0.1:18182',
+  dual: 'http://127.0.0.1:18183',
+  standaloneNew: 'http://127.0.0.1:18185',
+  standaloneApostrophe: 'http://127.0.0.1:18186',
+};
+
+module.exports = {
+  baseUrls,
+};

+ 12 - 0
tests/e2e/helpers/ui.js

@@ -0,0 +1,12 @@
+function modernStartButton(page) {
+  return page.locator('#start-button');
+}
+
+function classicStartButton(page) {
+  return page.locator('#startStopBtn');
+}
+
+module.exports = {
+  modernStartButton,
+  classicStartButton,
+};

+ 59 - 0
tests/e2e/modes.spec.js

@@ -0,0 +1,59 @@
+const { test, expect } = require('@playwright/test');
+const { baseUrls } = require('./helpers/env');
+const { modernStartButton } = require('./helpers/ui');
+
+const defaultTagline = 'No Flash, No Java, No Websockets, No Bullsh*t';
+
+test.describe('Runtime mode smoke coverage', () => {
+  test('standalone exposes UI and local backend endpoints', async ({ page, request }) => {
+    const root = await request.get(`${baseUrls.standalone}/`);
+    expect(root.ok()).toBeTruthy();
+
+    const index = await request.get(`${baseUrls.standalone}/index.html`);
+    expect(index.ok()).toBeTruthy();
+    await expect(await index.text()).toContain('design-switch.js');
+
+    for (const endpoint of ['/backend/empty.php', '/backend/garbage.php', '/backend/getIP.php']) {
+      const response = await request.get(`${baseUrls.standalone}${endpoint}`);
+      expect(response.ok()).toBeTruthy();
+    }
+
+    await page.goto(`${baseUrls.standalone}/index-modern.html`);
+    await expect(modernStartButton(page)).toBeVisible();
+    await expect(page.locator('main > p.tagline')).toHaveText(defaultTagline);
+  });
+
+  test('backend exposes only local backend contract endpoints', async ({ request }) => {
+    for (const endpoint of ['/empty.php', '/garbage.php', '/getIP.php']) {
+      const response = await request.get(`${baseUrls.backend}${endpoint}`);
+      expect(response.ok()).toBeTruthy();
+    }
+  });
+
+  test('frontend serves UI and server list without local backend contract', async ({ page, request }) => {
+    const serverList = await request.get(`${baseUrls.frontend}/server-list.json`);
+    expect(serverList.ok()).toBeTruthy();
+    await expect(await serverList.text()).toContain('Backend testpoint');
+
+    const localBackendEndpoint = await request.get(`${baseUrls.frontend}/backend/empty.php`);
+    expect(localBackendEndpoint.status()).toBe(404);
+
+    await page.goto(`${baseUrls.frontend}/index-modern.html`);
+    await expect(modernStartButton(page)).toBeVisible();
+    await expect(page.locator('#selected-server')).not.toHaveText(/searching nearest server/i);
+  });
+
+  test('dual combines frontend and local backend availability', async ({ page, request }) => {
+    const serverList = await request.get(`${baseUrls.dual}/server-list.json`);
+    expect(serverList.ok()).toBeTruthy();
+    await expect(await serverList.text()).toContain('Local dual backend');
+
+    for (const endpoint of ['/backend/empty.php', '/backend/garbage.php', '/backend/getIP.php']) {
+      const response = await request.get(`${baseUrls.dual}${endpoint}`);
+      expect(response.ok()).toBeTruthy();
+    }
+
+    await page.goto(`${baseUrls.dual}/index-modern.html`);
+    await expect(modernStartButton(page)).toBeVisible();
+  });
+});

+ 25 - 0
tests/e2e/title-special-chars.spec.js

@@ -0,0 +1,25 @@
+const { test, expect } = require('@playwright/test');
+const { baseUrls } = require('./helpers/env');
+
+const specialTitle = 'Grüße "Tempo" \'Österreich\'';
+const specialTagline = 'No "Flash", <No Java>, No Websockets & No Bullsh*t';
+const apostropheTagline = "It'd rather be fast!";
+
+test.describe('TITLE and TAGLINE special characters', () => {
+  test('modern page title supports umlauts and quotes', async ({ page }) => {
+    await page.goto(`${baseUrls.standaloneNew}/index-modern.html`);
+    await expect(page).toHaveTitle(`${specialTitle} - Free and Open Source Speedtest`);
+    await expect(page.locator('main > h1')).toHaveText(specialTitle);
+    await expect(page.locator('main > p.tagline')).toHaveText(specialTagline);
+  });
+
+  test('classic heading supports umlauts and quotes', async ({ page }) => {
+    await page.goto(`${baseUrls.standaloneNew}/index-classic.html`);
+    await expect(page.locator('h1').first()).toHaveText(specialTitle);
+  });
+
+  test('modern page tagline renders apostrophe correctly', async ({ page }) => {
+    await page.goto(`${baseUrls.standaloneApostrophe}/index-modern.html`);
+    await expect(page.locator('main > p.tagline')).toHaveText(apostropheTagline);
+  });
+});