فهرست منبع

Implement fromScratch design

Timendus 1 سال پیش
والد
کامیت
0642af83aa

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>

+ 397 - 0
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 ? ` <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 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: <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) {
+        privacyWarning.innerHTML = `<span>You are connected through:</span><br/>${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);
+}

+ 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: 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;
+    }
+  }
+}