diff --git a/frontend/fonts/Inter-latin-ext.woff2 b/frontend/fonts/Inter-latin-ext.woff2
new file mode 100644
index 000000000..887153b81
Binary files /dev/null and b/frontend/fonts/Inter-latin-ext.woff2 differ
diff --git a/frontend/fonts/Inter-latin.woff2 b/frontend/fonts/Inter-latin.woff2
new file mode 100644
index 000000000..798d6d9f6
Binary files /dev/null and b/frontend/fonts/Inter-latin.woff2 differ
diff --git a/frontend/images/background-original.jpeg b/frontend/images/background-original.jpeg
new file mode 100644
index 000000000..8eef32ac8
Binary files /dev/null and b/frontend/images/background-original.jpeg differ
diff --git a/frontend/images/background.jpeg b/frontend/images/background.jpeg
new file mode 100644
index 000000000..e0f891641
Binary files /dev/null and b/frontend/images/background.jpeg differ
diff --git a/frontend/images/chevron.svg b/frontend/images/chevron.svg
new file mode 100644
index 000000000..d520abd12
--- /dev/null
+++ b/frontend/images/chevron.svg
@@ -0,0 +1,22 @@
+
\ No newline at end of file
diff --git a/frontend/images/close-button.svg b/frontend/images/close-button.svg
new file mode 100644
index 000000000..befe4e6be
--- /dev/null
+++ b/frontend/images/close-button.svg
@@ -0,0 +1,14 @@
+
+
\ No newline at end of file
diff --git a/frontend/images/favicon.svg b/frontend/images/favicon.svg
new file mode 100644
index 000000000..a38e24d64
--- /dev/null
+++ b/frontend/images/favicon.svg
@@ -0,0 +1,19 @@
+
+
\ No newline at end of file
diff --git a/frontend/images/logo.svg b/frontend/images/logo.svg
new file mode 100644
index 000000000..709aa1cf8
--- /dev/null
+++ b/frontend/images/logo.svg
@@ -0,0 +1,66 @@
+
\ No newline at end of file
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100755
index 000000000..cf7b7b04a
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+
+
+
+
+ LibreSpeed - Free and Open Source Speedtest
+
+
+
+
+
+
+
+ Free and Open Source Speedtest.
+ No Flash, No Java, No Websockets, No Bullsh*t
+
+
+
+
+
+
+
current server
+
searching nearest server...
+
+
+
+
+
+
+ by clicking the start button you agree to our privacy policy
+ or choose your privacy options
+
+
+
+
+
+ Ping:
+ 00ms
+
+
+
+
+
+
00 Mbps
+
Download
+
+
+
+
+
+ Jitter:
+ 00ms
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/javascript/index.js b/frontend/javascript/index.js
new file mode 100644
index 000000000..200a45c36
--- /dev/null
+++ b/frontend/javascript/index.js
@@ -0,0 +1,397 @@
+/**
+ * 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("#share img").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 server-list.json on the server and populate the
+ * dropdown
+ */
+async function applyServerListJSON() {
+ try {
+ const response = await fetch("server-list.json");
+ const servers = await response.json();
+ if (!servers || !Array.isArray(servers) || servers.length === 0) {
+ return console.error("Server list is empty or malformed");
+ }
+ testState.servers = servers;
+ populateDropdown(testState.servers);
+ if (servers.length > 1) {
+ testState.speedtest.addTestPoints(servers);
+ testState.speedtest.selectServer((server) => {
+ if (server) {
+ selectServer(server);
+ } 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 fetch 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");
+ serverSelector.classList.add("active");
+
+ // If we have only a single server, just show it
+ if (servers.length === 1) {
+ serverSelector.classList.add("single-server");
+ selectServer(servers[0]);
+ return;
+ }
+
+ // Make the dropdown open and close
+ 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 ? ` (${server.sponsorName})` : ""
+ }`;
+ 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 baby",
+ [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: ${server.sponsorName}`;
+ } else {
+ sponsor.textContent = `Sponsor: ${server.sponsorName}`;
+ }
+ } else {
+ sponsor.innerHTML = " ";
+ }
+ 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) {
+ privacyWarning.innerHTML = `You are connected through:
${testState.testData.clientIp}`;
+ 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);
+}
diff --git a/frontend/server-list.json b/frontend/server-list.json
new file mode 100644
index 000000000..01a8e8c21
--- /dev/null
+++ b/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"
+ }
+]
diff --git a/frontend/settings.json b/frontend/settings.json
new file mode 100644
index 000000000..8d2967c79
--- /dev/null
+++ b/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
+}
diff --git a/frontend/styling/button.css b/frontend/styling/button.css
new file mode 100644
index 000000000..7a25829f6
--- /dev/null
+++ b/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);
+ }
+}
diff --git a/frontend/styling/colors.css b/frontend/styling/colors.css
new file mode 100644
index 000000000..6a948d4c2
--- /dev/null
+++ b/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;
+}
diff --git a/frontend/styling/dialog.css b/frontend/styling/dialog.css
new file mode 100644
index 000000000..cfbfe03f3
--- /dev/null
+++ b/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;
+ }
+}
diff --git a/frontend/styling/fonts.css b/frontend/styling/fonts.css
new file mode 100644
index 000000000..abd7203d3
--- /dev/null
+++ b/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;
+}
diff --git a/frontend/styling/index.css b/frontend/styling/index.css
new file mode 100644
index 000000000..d8abd9c0d
--- /dev/null
+++ b/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;
+ }
+}
diff --git a/frontend/styling/main.css b/frontend/styling/main.css
new file mode 100644
index 000000000..530ed4da9
--- /dev/null
+++ b/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;
+ }
+ }
+}
diff --git a/frontend/styling/results.css b/frontend/styling/results.css
new file mode 100644
index 000000000..55a281e7d
--- /dev/null
+++ b/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;
+}
diff --git a/frontend/styling/server-selector.css b/frontend/styling/server-selector.css
new file mode 100644
index 000000000..d4906c8f4
--- /dev/null
+++ b/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: 32;
+ height: 32;
+ 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;
+ }
+ }
+}