Fold the connection banner into the notification stack

The offline pill was drawn inside a `flex h-0` container with align-items at
its default stretch, so the pill was stretched to a zero-height box while still
carrying its padding — the "box too small" it was reported as. It is a
persistent notification now, keyed on one id so a poll that keeps confirming
"still offline" replaces the card instead of restacking it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 16:00:56 +02:00
co-authored by Claude Opus 5
parent 44c8825927
commit 6d09500a73
4 changed files with 55 additions and 63 deletions
@@ -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 (
<div className="pointer-events-none sticky top-4 z-50 flex h-0 justify-center px-4">
<AnimatePresence>
{connection.offline && (
<motion.div
variants={fadeIn}
initial="hidden"
animate="visible"
exit="hidden"
role="status"
aria-live="polite"
className="flex items-center gap-2 rounded-full border border-border bg-card/80 px-4 py-2 text-sm shadow-e2 backdrop-blur-md"
>
<WifiOff className="size-4 shrink-0 text-muted-foreground" />
<span className="font-medium">Installation offline</span>
<span className="text-muted-foreground">
{connection.lastSeen
? `last seen ${ago(connection.lastSeen / 1000)} — reconnecting…`
: "reconnecting…"}
</span>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
@@ -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
}
@@ -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.
+4 -4
View File
@@ -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() {
<SidebarProvider className="bg-card">
<AppSidebar />
<SidebarInset className="bg-card">
{/* Renders nothing unless this page is served through a portal.
Inside the inset so it centres over the content, not the window. */}
<ConnectionBanner />
{/* Draws nothing itself, and nothing at all unless this page is
served through a portal: it raises the offline notification. */}
<ConnectionNotice />
{/* The sidebar carries its own collapse control; a phone has no
sidebar on screen to carry it. */}
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 px-4 md:hidden">