diff --git a/.gitignore b/.gitignore
index f7cd28a5a7442878e937728999cb6803d04f6210..ed80ebed950ce1fbe33ad36b6eccbe9162d0d98e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,10 @@
 # Os generated files
 .DS_Store
 
-# Build related
-/target
+# trunk output folder
+dist
+
+# Rust compile target directories:
+target
+target_ra
+target_wasm
diff --git a/Cargo.lock b/Cargo.lock
index 315eaa6e6c6b9903edf25c6cb9a2c0a3630a206c..c6681b5370ae724638fba860cfce97546faba939 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2617,6 +2617,8 @@ dependencies = [
  "log",
  "serde",
  "serde_json",
+ "wasm-bindgen-futures",
+ "web-sys",
 ]
 
 [[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 6a26a05776fdee570e1ffe58d58df61c6d1dc6cb..94436f6c188a6ce95d4a52b4fd6de86fef051754 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -22,3 +22,7 @@ log = "0.4"
 # =========== Utility ===========
 # for dynamic dispatch
 enum_dispatch = "0.3"
+
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+wasm-bindgen-futures = "0.4"
+web-sys = "0.3.70"           # to access the DOM (to hide the loading text)
diff --git a/assets/apple-touch-icon.png b/assets/apple-touch-icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..9f783f8513dcd23129e26bde3613763fef87e6b0
Binary files /dev/null and b/assets/apple-touch-icon.png differ
diff --git a/assets/favicon.ico b/assets/favicon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..1db51358f1426f8bf8bb12cc9ebdb298baa9cbaf
Binary files /dev/null and b/assets/favicon.ico differ
diff --git a/assets/icon-1024.png b/assets/icon-1024.png
new file mode 100644
index 0000000000000000000000000000000000000000..454f84d9068717f8e88f52d9417e831e66434880
Binary files /dev/null and b/assets/icon-1024.png differ
diff --git a/assets/icon-256.png b/assets/icon-256.png
new file mode 100644
index 0000000000000000000000000000000000000000..544fa819b58e04220267e5927814ba339e2ec0e3
Binary files /dev/null and b/assets/icon-256.png differ
diff --git a/assets/manifest.json b/assets/manifest.json
new file mode 100644
index 0000000000000000000000000000000000000000..6ad27f17168185cb47f95b2c7a1a761f5b9061b6
--- /dev/null
+++ b/assets/manifest.json
@@ -0,0 +1,22 @@
+{
+    "name": "Skyward Enhanced Ground Station",
+    "short_name": "segs",
+    "icons": [
+        {
+            "src": "./logo_256.png",
+            "sizes": "256x256",
+            "type": "image/png"
+        },
+        {
+            "src": "./logo_1024.png",
+            "sizes": "1024x1024",
+            "type": "image/png"
+        }
+    ],
+    "lang": "en-US",
+    "id": "/index.html",
+    "start_url": "./index.html",
+    "display": "standalone",
+    "background_color": "white",
+    "theme_color": "white"
+}
diff --git a/assets/sw.js b/assets/sw.js
new file mode 100644
index 0000000000000000000000000000000000000000..2113f90204cfefe66ff65daf7d9b5824173f4f7a
--- /dev/null
+++ b/assets/sw.js
@@ -0,0 +1,25 @@
+var cacheName = "egui-template-pwa";
+var filesToCache = [
+    "./",
+    "./index.html",
+    "./eframe_template.js",
+    "./eframe_template_bg.wasm",
+];
+
+/* Start the service worker and cache all of the app's content */
+self.addEventListener("install", function (e) {
+    e.waitUntil(
+        caches.open(cacheName).then(function (cache) {
+            return cache.addAll(filesToCache);
+        }),
+    );
+});
+
+/* Serve cached content when offline */
+self.addEventListener("fetch", function (e) {
+    e.respondWith(
+        caches.match(e.request).then(function (response) {
+            return response || fetch(e.request);
+        }),
+    );
+});
diff --git a/index.html b/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..725b781090ec352803f84e59f979bf30ccc79722
--- /dev/null
+++ b/index.html
@@ -0,0 +1,175 @@
+<!doctype html>
+<html>
+    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
+
+    <!-- Disable zooming: -->
+    <meta
+        name="viewport"
+        content="width=device-width, initial-scale=1.0, user-scalable=no"
+    />
+
+    <head>
+        <title>Skyward Enhanced Ground Station</title>
+
+        <!-- config for our rust wasm binary. go to https://trunkrs.dev/assets/#rust for more customization -->
+        <link data-trunk rel="rust" data-wasm-opt="2" />
+        <!-- this is the base url relative to which other urls will be constructed. trunk will insert this from the public-url option -->
+        <base data-trunk-public-url />
+
+        <link data-trunk rel="icon" href="assets/favicon.ico" />
+
+        <link data-trunk rel="copy-file" href="assets/sw.js" />
+        <link
+            data-trunk
+            rel="copy-file"
+            href="assets/manifest.json"
+            data-target-path="assets"
+        />
+        <link
+            data-trunk
+            rel="copy-file"
+            href="assets/icon-1024.png"
+            data-target-path="assets"
+        />
+        <link
+            data-trunk
+            rel="copy-file"
+            href="assets/icon-256.png"
+            data-target-path="assets"
+        />
+        <link
+            data-trunk
+            rel="copy-file"
+            href="assets/apple-touch-icon.png"
+            data-target-path="assets"
+        />
+
+        <link rel="manifest" href="assets/manifest.json" />
+        <link rel="apple-touch-icon" href="assets/apple-touch-icon.png" />
+        <meta
+            name="theme-color"
+            media="(prefers-color-scheme: light)"
+            content="white"
+        />
+        <meta
+            name="theme-color"
+            media="(prefers-color-scheme: dark)"
+            content="#404040"
+        />
+
+        <style>
+            html {
+                /* Remove touch delay: */
+                touch-action: manipulation;
+            }
+
+            body {
+                /* Light mode background color for what is not covered by the egui canvas,
+                   or where the egui canvas is translucent. */
+                background: #909090;
+            }
+
+            @media (prefers-color-scheme: dark) {
+                body {
+                    /* Dark mode background color for what is not covered by the egui canvas,
+                or where the egui canvas is translucent. */
+                    background: #404040;
+                }
+            }
+
+            /* Allow canvas to fill entire web page: */
+            html,
+            body {
+                overflow: hidden;
+                margin: 0 !important;
+                padding: 0 !important;
+                height: 100%;
+                width: 100%;
+            }
+
+            /* Make canvas fill entire document: */
+            canvas {
+                margin-right: auto;
+                margin-left: auto;
+                display: block;
+                position: absolute;
+                top: 0;
+                left: 0;
+                width: 100%;
+                height: 100%;
+            }
+
+            .centered {
+                margin-right: auto;
+                margin-left: auto;
+                display: block;
+                position: absolute;
+                top: 50%;
+                left: 50%;
+                transform: translate(-50%, -50%);
+                color: #f0f0f0;
+                font-size: 24px;
+                font-family: Ubuntu-Light, Helvetica, sans-serif;
+                text-align: center;
+            }
+
+            /* ---------------------------------------------- */
+            /* Loading animation from https://loading.io/css/ */
+            .lds-dual-ring {
+                display: inline-block;
+                width: 24px;
+                height: 24px;
+            }
+
+            .lds-dual-ring:after {
+                content: " ";
+                display: block;
+                width: 24px;
+                height: 24px;
+                margin: 0px;
+                border-radius: 50%;
+                border: 3px solid #fff;
+                border-color: #fff transparent #fff transparent;
+                animation: lds-dual-ring 1.2s linear infinite;
+            }
+
+            @keyframes lds-dual-ring {
+                0% {
+                    transform: rotate(0deg);
+                }
+
+                100% {
+                    transform: rotate(360deg);
+                }
+            }
+        </style>
+    </head>
+
+    <body>
+        <!-- The WASM code will resize the canvas dynamically -->
+        <!-- the id is hardcoded in main.rs . so, make sure both match. -->
+        <canvas id="segs_canvas"></canvas>
+
+        <!-- the loading spinner will be removed in main.rs -->
+        <div class="centered" id="loading_text">
+            <p style="font-size: 16px">Loading…</p>
+            <div class="lds-dual-ring"></div>
+        </div>
+
+        <!--Register Service Worker. this will cache the wasm / js scripts for offline use (for PWA functionality). -->
+        <!-- Force refresh (Ctrl + F5) to load the latest files instead of cached files  -->
+        <script>
+            // We disable caching during development so that we always view the latest version.
+            if (
+                "serviceWorker" in navigator &&
+                window.location.hash !== "#dev"
+            ) {
+                window.addEventListener("load", function () {
+                    navigator.serviceWorker.register("sw.js");
+                });
+            }
+        </script>
+    </body>
+</html>
+
+<!-- Powered by egui: https://github.com/emilk/egui/ -->
diff --git a/src/main.rs b/src/main.rs
index 64e7c45fc68288774b9c7da110acf79273a06718..c03d0546d2ac340c6814c6e1778542a5edcb3f34 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2,6 +2,7 @@ use ui::ComposableView;
 
 mod ui;
 
+#[cfg(not(target_arch = "wasm32"))]
 fn main() -> Result<(), eframe::Error> {
     // set up logging (USE RUST_LOG=debug to see logs)
     env_logger::init();
@@ -25,3 +26,49 @@ fn main() -> Result<(), eframe::Error> {
         Box::new(|_| Ok(Box::<ComposableView>::default())),
     )
 }
+
+#[cfg(target_arch = "wasm32")]
+fn main() {
+    use eframe::wasm_bindgen::JsCast as _;
+
+    // Redirect `log` message to `console.log` and friends:
+    eframe::WebLogger::init(log::LevelFilter::Debug).ok();
+
+    let web_options = eframe::WebOptions::default();
+
+    wasm_bindgen_futures::spawn_local(async {
+        let document = web_sys::window()
+            .expect("No window")
+            .document()
+            .expect("No document");
+
+        let canvas = document
+            .get_element_by_id("segs_canvas")
+            .expect("Failed to find the_canvas_id")
+            .dyn_into::<web_sys::HtmlCanvasElement>()
+            .expect("segs_canvas was not a HtmlCanvasElement");
+
+        let start_result = eframe::WebRunner::new()
+            .start(
+                canvas,
+                web_options,
+                Box::new(|_| Ok(Box::<ComposableView>::default())),
+            )
+            .await;
+
+        // Remove the loading text and spinner:
+        if let Some(loading_text) = document.get_element_by_id("loading_text") {
+            match start_result {
+                Ok(_) => {
+                    loading_text.remove();
+                }
+                Err(e) => {
+                    loading_text.set_inner_html(
+                        "<p> The app has crashed. See the developer console for details. </p>",
+                    );
+                    panic!("Failed to start eframe: {e:?}");
+                }
+            }
+        }
+    });
+}