diff --git a/frontend/src/components/Common/ConnectionBanner.tsx b/frontend/src/components/Common/ConnectionBanner.tsx
deleted file mode 100644
index 9c26577..0000000
--- a/frontend/src/components/Common/ConnectionBanner.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import { WifiOff } from "lucide-react"
-import { AnimatePresence, motion } from "motion/react"
-import { useSyncExternalStore } from "react"
-
-import { ago } from "@/components/Health/queries"
-import { connectionStore } from "@/lib/connectionStore"
-import { fadeIn } from "@/lib/motion"
-import { isPortal } from "@/lib/portal"
-
-/**
- * Says when the installation cannot be reached, and when it was last heard
- * from.
- *
- * Only ever shown under a portal: a local install cannot lose contact with
- * itself. The screen underneath keeps its last data rather than blanking —
- * stale readings with a timestamp are more use than an empty page, which is
- * why the banner leads with when we last heard anything.
- *
- * Centred over the content column rather than the window: it belongs to what
- * is being read, not to the shell around it. That is what `sticky` inside the
- * content column buys — no sidebar width to track and nothing to recompute
- * when the sidebar collapses — and `h-0` keeps it out of the flow, so nothing
- * below it moves when the banner appears.
- */
-export function ConnectionBanner() {
- const connection = useSyncExternalStore(
- connectionStore.subscribe,
- connectionStore.snapshot,
- connectionStore.snapshot,
- )
- if (!isPortal()) return null
-
- return (
-
-
- {connection.offline && (
-
-
- Installation offline
-
- {connection.lastSeen
- ? `last seen ${ago(connection.lastSeen / 1000)} — reconnecting…`
- : "reconnecting…"}
-
-
- )}
-
-
- )
-}
diff --git a/frontend/src/components/Common/ConnectionNotice.tsx b/frontend/src/components/Common/ConnectionNotice.tsx
new file mode 100644
index 0000000..5d29b41
--- /dev/null
+++ b/frontend/src/components/Common/ConnectionNotice.tsx
@@ -0,0 +1,50 @@
+import { useEffect, useSyncExternalStore } from "react"
+
+import { ago } from "@/components/Health/queries"
+import { connectionStore } from "@/lib/connectionStore"
+import { dismiss, notify } from "@/lib/notificationStore"
+import { isPortal } from "@/lib/portal"
+
+/** One card, replaced in place, rather than one per poll that confirms it. */
+const ID = "connection-offline"
+
+/**
+ * Says when the installation cannot be reached, and when it was last heard
+ * from.
+ *
+ * Only ever meaningful under a portal: a local install cannot lose contact with
+ * itself. The screen underneath keeps its last data rather than blanking —
+ * stale readings with a timestamp are more use than an empty page, which is why
+ * this leads with when we last heard anything.
+ *
+ * Draws nothing of its own: it raises a persistent notification and takes it
+ * back when the tunnel returns, so being offline is said in the same place as
+ * everything else the app has to say. Still a component rather than a
+ * subscription inside the store, because it is what scopes the notice to the
+ * shell — a wall panel or a kiosk has nobody to read it.
+ */
+export function ConnectionNotice() {
+ const connection = useSyncExternalStore(
+ connectionStore.subscribe,
+ connectionStore.snapshot,
+ connectionStore.snapshot,
+ )
+ const portal = isPortal()
+
+ useEffect(() => {
+ if (!portal) return
+ if (!connection.offline) {
+ dismiss(ID)
+ return
+ }
+ notify(
+ connection.lastSeen
+ ? `Installation offline — last seen ${ago(connection.lastSeen / 1000)}, reconnecting…`
+ : "Installation offline — reconnecting…",
+ "warning",
+ { id: ID, persistent: true },
+ )
+ }, [portal, connection])
+
+ return null
+}
diff --git a/frontend/src/components/Health/LiveIndicator.tsx b/frontend/src/components/Health/LiveIndicator.tsx
index cfbb491..55d182d 100644
--- a/frontend/src/components/Health/LiveIndicator.tsx
+++ b/frontend/src/components/Health/LiveIndicator.tsx
@@ -23,7 +23,7 @@ const GRACE = 3000
* stop pulsing and the values stop changing, with nothing to say why. Nothing
* at all while the socket is up: a page that works needs no chip saying so.
*
- * Quiet too while `ConnectionBanner` is up, since an installation that cannot
+ * Quiet too while the offline notification is up, since an installation that cannot
* be reached has no socket either and one explanation of that is enough. Named
* in words rather than coloured, and deliberately not terracotta: the brain
* graph above it already owns that accent for a flow that cannot run.
diff --git a/frontend/src/routes/_layout.tsx b/frontend/src/routes/_layout.tsx
index fba8a81..63bd00f 100644
--- a/frontend/src/routes/_layout.tsx
+++ b/frontend/src/routes/_layout.tsx
@@ -1,6 +1,6 @@
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"
-import { ConnectionBanner } from "@/components/Common/ConnectionBanner"
+import { ConnectionNotice } from "@/components/Common/ConnectionNotice"
import { Footer } from "@/components/Common/Footer"
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
import AppSidebar from "@/components/Sidebar/AppSidebar"
@@ -31,9 +31,9 @@ function Layout() {
- {/* Renders nothing unless this page is served through a portal.
- Inside the inset so it centres over the content, not the window. */}
-
+ {/* Draws nothing itself, and nothing at all unless this page is
+ served through a portal: it raises the offline notification. */}
+
{/* The sidebar carries its own collapse control; a phone has no
sidebar on screen to carry it. */}