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. */}