diff --git a/README.md b/README.md
index 5d20b0ed8..c25628f0d 100644
--- a/README.md
+++ b/README.md
@@ -91,6 +91,7 @@ The `editor-wc` tag accepts the following attributes, which must be provided as
- `output_split_view`: Start with split view in output panel (defaults to `false`, i.e. tabbed view)
- `project_name_editable`: Allow the user to edit the project name in the project bar (defaults to `false`)
- `react_app_api_endpoint`: API endpoint to send project-related requests to
+- `offline_enabled`: Show an offline indicator when the user's device loses connectivity (defaults to `false`). Requires the host page's service worker to broadcast `{ type: "OFFLINE" }` / `{ type: "ONLINE" }` messages - see [Offline support](#offline-support).
- `read_only`: Display the editor in read only mode (defaults to `false`)
- `sense_hat_always_enabled`: Show the Astro Pi Sense HAT emulator on page load (defaults to `false`)
- `show_save_prompt`: Prompt the user to save their work (defaults to `false`)
@@ -130,6 +131,30 @@ The host page is able to communicate with the web component via custom methods p
This allows the host page to query the current code in the editor and to control code runs from outside the web component, for example.
+### Offline support
+
+The web component displays an offline indicator when the host page's service worker broadcasts connectivity changes. The web component itself does not register a service worker - caching and offline detection are the host app's responsibility.
+
+To enable the offline indicator:
+
+1. Have your host page's service worker broadcast `{ type: "OFFLINE" }` when a network request falls back to cache, and `{ type: "ONLINE" }` when the network recovers:
+ ```js
+ // In your service worker
+ self.clients.matchAll().then(clients =>
+ clients.forEach(c => c.postMessage({ type: "OFFLINE" }))
+ );
+ ```
+2. Pass the `offline_enabled` attribute to the web component:
+ ```html
+
+ ```
+
+Offline mode is opt-in - the offline badge will not appear unless both steps are taken.
+
+#### Developing with offline support
+
+Set `offline_enabled="true"` on the web component (already the default in the dev HTML), then use the **Network** tab in browser DevTools to toggle offline mode. The browser's `offline` event fires immediately and the offline indicator will appear.
+
## Development
### Previewing
diff --git a/docs/OfflineServiceWorker.md b/docs/OfflineServiceWorker.md
new file mode 100644
index 000000000..1eb38670c
--- /dev/null
+++ b/docs/OfflineServiceWorker.md
@@ -0,0 +1,207 @@
+# Offline service worker example
+
+The editor-ui web component supports an offline indicator via the `offline_enabled` attribute. The offline UI is driven by `OFFLINE` / `ONLINE` messages broadcast from the host page's (ie. editor-standalone) service worker to all controlled clients - see the [Offline support section of the README](../README.md#offline-support) for the full integration contract.
+
+This document describes a service worker that could be bundled with editor-ui itself (rather than relying on the host app / editor-standalone). It is a working reference for any host wanting to implement the required messaging contract from scratch.
+
+## What the service worker must do
+
+For the editor-ui offline indicator to work, the controlling service worker must:
+
+1. Broadcast `{ type: "OFFLINE" }` to all clients when a network-first fetch falls back to cache (ie. the network is unreachable)
+2. Broadcast `{ type: "ONLINE" }` to all clients when a network-first fetch succeeds after a period of serving from cache (i.e. the network has recovered)
+3. Handle a `{ type: "CHECK_ONLINE" }` message from the client by probing the network and broadcasting `ONLINE` if the probe succeeds. This is used by `useIsOnline` to poll for recovery while offline, since no fetch may happen naturally
+
+## Example service worker
+
+The implementation below could be shipped as `public/service-worker.js` in editor-ui. It caches the editor shell and Pyodide assets and implements the messaging contract above.
+
+> **Note:** Cache version strings (`editor-app-v1`, `editor-translations-v1`) would usually be replaced, by the host app, with the package version at build time via a webpack `CopyWebpackPlugin` transform. If you adopt this SW you will need to wire up equivalent versioning, or replace them with a fixed string and manage cache invalidation another way.
+
+```js
+/* eslint-env serviceworker */
+/* eslint-disable no-restricted-globals */
+
+// "editor-app-v1" and "editor-translations-v1" are replaced with the package version at build time
+const APP_CACHE = "editor-app-v1";
+const TRANSLATIONS_CACHE = "editor-translations-v1";
+const PYODIDE_CACHE = "pyodide-v0.26.2";
+
+// Minimal set of assets to pre-cache on install
+// All other assets (chunks, translations, etc.) are cached dynamically on first use via the network-first fetch handler below
+const APP_SHELL = [
+ "./web-component.html",
+ "./web-component.js",
+ "./PyodideWorker.js",
+ "./manifest.json",
+];
+
+self.addEventListener("install", (event) => {
+ event.waitUntil(
+ Promise.all([
+ caches
+ .open(APP_CACHE)
+ .then((cache) =>
+ cache
+ .addAll(APP_SHELL)
+ .catch((err) =>
+ console.warn(
+ "[SW] Pre-cache failed, will rely on dynamic caching:",
+ err,
+ ),
+ ),
+ ),
+ caches
+ .open(TRANSLATIONS_CACHE)
+ .then((cache) =>
+ cache
+ .addAll(["./translations/en.json"])
+ .catch((err) =>
+ console.warn(
+ "[SW] Translation pre-cache failed, will rely on dynamic caching:",
+ err,
+ ),
+ ),
+ ),
+ ]),
+ );
+ self.skipWaiting();
+});
+
+self.addEventListener("activate", (event) => {
+ event.waitUntil(
+ caches.keys().then((keys) =>
+ Promise.all(
+ keys
+ .filter(
+ (key) =>
+ key !== APP_CACHE &&
+ key !== TRANSLATIONS_CACHE &&
+ key !== PYODIDE_CACHE,
+ )
+ .map((key) => {
+ console.log("[SW] Deleting old cache:", key);
+ return caches.delete(key);
+ }),
+ ),
+ ),
+ );
+ self.clients.claim();
+});
+
+// Pyodide needs SharedArrayBuffer, which requires cross-origin isolation (COOP + COEP on the HTML response, CORP on every cross-origin resource)
+// We re-apply those headers when caching responses so they are preserved when served from cache offline.
+function addSecurityHeaders(response) {
+ if (response.type === "opaque") return response;
+ const headers = new Headers(response.headers);
+ headers.set("Cross-Origin-Opener-Policy", "same-origin");
+ headers.set("Cross-Origin-Embedder-Policy", "require-corp");
+ headers.set("Cross-Origin-Resource-Policy", "cross-origin");
+ return new Response(response.body, {
+ status: response.status,
+ statusText: response.statusText,
+ headers,
+ });
+}
+
+// Tracks whether any network-first request has fallen back to cache, so we know to broadcast ONLINE when the network becomes reachable again.
+let servingFromCache = false;
+
+self.addEventListener("online", () => {
+ servingFromCache = false;
+ broadcast("ONLINE");
+});
+
+// Handle CHECK_ONLINE from the client (sent by useIsOnline while offline).
+// SW-initiated fetches bypass the SW's own fetch handler, so this hits the network directly without being served from cache
+self.addEventListener("message", (event) => {
+ if (event.data?.type !== "CHECK_ONLINE") return;
+ fetch("./manifest.json", { cache: "no-store" })
+ .then(() => {
+ servingFromCache = false;
+ broadcast("ONLINE");
+ })
+ .catch(() => {});
+});
+
+function broadcast(type) {
+ self.clients
+ .matchAll()
+ .then((clients) => clients.forEach((c) => c.postMessage({ type })));
+}
+
+// Network-first: try the network, update the cache, fall back to cache.
+async function networkFirst(request, cacheName) {
+ const cache = await caches.open(cacheName);
+ try {
+ const networkResponse = await fetch(request);
+ if (networkResponse.ok) {
+ cache.put(request, addSecurityHeaders(networkResponse.clone()));
+ if (servingFromCache) {
+ servingFromCache = false;
+ broadcast("ONLINE");
+ }
+ }
+ return addSecurityHeaders(networkResponse);
+ } catch {
+ const cached = await cache.match(request);
+ if (cached) {
+ servingFromCache = true;
+ broadcast("OFFLINE");
+ return addSecurityHeaders(cached);
+ }
+ return Response.error();
+ }
+}
+
+// Cache-first: serve from cache when available, populate on first fetch.
+// importScripts produces opaque responses we can't modify, so we re-fetch as cors to get a modifiable response we can store with security headers
+async function cacheFirst(request, cacheName) {
+ const cache = await caches.open(cacheName);
+ const cached = await cache.match(request.url);
+ if (cached) return cached;
+
+ const corsRequest = new Request(request.url, {
+ mode: "cors",
+ credentials: "omit",
+ });
+ const networkResponse = await fetch(corsRequest);
+ cache.put(request.url, addSecurityHeaders(networkResponse.clone()));
+ return addSecurityHeaders(networkResponse);
+}
+
+self.addEventListener("fetch", (event) => {
+ // Chrome bug: skip only-if-cached requests for cross-origin resources
+ if (
+ event.request.cache === "only-if-cached" &&
+ event.request.mode !== "same-origin"
+ ) {
+ return;
+ }
+
+ const url = new URL(event.request.url);
+
+ // Pyodide CDN assets are cache-first since their URLs are version-pinned
+ if (
+ url.hostname === "cdn.jsdelivr.net" &&
+ url.pathname.includes("/pyodide/")
+ ) {
+ event.respondWith(cacheFirst(event.request, PYODIDE_CACHE));
+ return;
+ }
+
+ // Translation files get their own cache so they can be evicted independently of the app shell
+ if (
+ url.origin === self.location.origin &&
+ url.pathname.includes("/translations/")
+ ) {
+ event.respondWith(networkFirst(event.request, TRANSLATIONS_CACHE));
+ return;
+ }
+
+ // Same-origin app assets are network-first so users get fresh content online
+ if (url.origin === self.location.origin) {
+ event.respondWith(networkFirst(event.request, APP_CACHE));
+ }
+});
+```
diff --git a/public/translations/en.json b/public/translations/en.json
index 584618c9f..7b2b2c7cf 100644
--- a/public/translations/en.json
+++ b/public/translations/en.json
@@ -83,6 +83,9 @@
"renameSave": "Save project name",
"save": "Save",
"loginToSave": "Log in to save",
+ "offline": "Offline",
+ "offlineTooltipDevice": "Code changes are being saved to your device.",
+ "offlineTooltipContinue": "You can keep coding and your work will be saved when you are back online.",
"settings": "Settings"
},
"imagePanel": {
diff --git a/src/assets/icons/offline.svg b/src/assets/icons/offline.svg
new file mode 100644
index 000000000..0e4cb349a
--- /dev/null
+++ b/src/assets/icons/offline.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/assets/stylesheets/InternalStyles.scss b/src/assets/stylesheets/InternalStyles.scss
index 01f136b70..f790feb70 100644
--- a/src/assets/stylesheets/InternalStyles.scss
+++ b/src/assets/stylesheets/InternalStyles.scss
@@ -37,6 +37,7 @@
@use "./Button" as *;
@use "./DesignSystemButton" as *;
@use "./SaveStatus" as *;
+@use "./OfflineIndicator" as *;
@use "./ContextMenu" as *;
@use "./FilePanel" as *; // needs to be below Button
@use "./EmbeddedViewer" as *;
diff --git a/src/assets/stylesheets/OfflineIndicator.scss b/src/assets/stylesheets/OfflineIndicator.scss
new file mode 100644
index 000000000..f257799bf
--- /dev/null
+++ b/src/assets/stylesheets/OfflineIndicator.scss
@@ -0,0 +1,67 @@
+@use "./rpf_design_system/colours" as *;
+@use "./rpf_design_system/spacing" as *;
+
+.offline-badge {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ gap: $space-0-5;
+ padding-inline: $space-0-5;
+ padding-block: $space-0-5;
+ border-radius: 100vw;
+ border: 3px solid $rpf-red-400;
+ color: $rpf-red-900;
+ white-space: nowrap;
+ cursor: default;
+ background-color: $rpf-red-100;
+ font-weight: bold;
+
+ svg {
+ flex-shrink: 0;
+ }
+
+ &__tooltip {
+ position: absolute;
+ inset-block-start: calc(100% + $space-1);
+ inset-inline-end: 0;
+ inline-size: 12rem;
+ padding: $space-1;
+ border-radius: $space-0-5;
+ background: #1d1d1d;
+ color: #fff;
+ font-size: 1rem;
+ white-space: normal;
+ opacity: 0;
+ visibility: hidden;
+ pointer-events: none;
+ transition: opacity 0.15s ease;
+ z-index: 100;
+
+ &::before {
+ content: "";
+ position: absolute;
+ inset-block-start: -8px;
+ inset-inline-end: 40px;
+ inline-size: 0;
+ block-size: 0;
+ border-inline-start: 8px solid transparent;
+ border-inline-end: 8px solid transparent;
+ border-block-end: 8px solid #1d1d1d;
+ }
+
+ p {
+ margin: 0;
+
+ & + p {
+ margin-block-start: $space-1-5;
+ }
+ }
+ }
+
+ &:hover &__tooltip,
+ &:focus-within &__tooltip {
+ opacity: 1;
+ visibility: visible;
+ pointer-events: auto;
+ }
+}
diff --git a/src/components/Mobile/MobileProjectBar/MobileProjectBar.jsx b/src/components/Mobile/MobileProjectBar/MobileProjectBar.jsx
index 4a4080dde..0f55b771a 100644
--- a/src/components/Mobile/MobileProjectBar/MobileProjectBar.jsx
+++ b/src/components/Mobile/MobileProjectBar/MobileProjectBar.jsx
@@ -1,17 +1,26 @@
import "../../../assets/stylesheets/MobileProjectBar.scss";
import SaveStatus from "../../SaveStatus/SaveStatus";
+import OfflineBadge from "../../OfflineBadge/OfflineBadge";
import { useSelector } from "react-redux";
+import useIsOnline from "../../../hooks/useIsOnline";
import React from "react";
const MobileProjectBar = () => {
const projectName = useSelector((state) => state.editor.project.name);
const lastSavedTime = useSelector((state) => state.editor.lastSavedTime);
+ const offlineEnabled = useSelector((state) => state.editor.offlineEnabled);
const readOnly = useSelector((state) => state.editor.readOnly);
+ const isOnline = useIsOnline();
return (