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:
@@ -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
|
* 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.
|
* 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
|
* 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
|
* in words rather than coloured, and deliberately not terracotta: the brain
|
||||||
* graph above it already owns that accent for a flow that cannot run.
|
* graph above it already owns that accent for a flow that cannot run.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"
|
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 { Footer } from "@/components/Common/Footer"
|
||||||
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
|
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
|
||||||
import AppSidebar from "@/components/Sidebar/AppSidebar"
|
import AppSidebar from "@/components/Sidebar/AppSidebar"
|
||||||
@@ -31,9 +31,9 @@ function Layout() {
|
|||||||
<SidebarProvider className="bg-card">
|
<SidebarProvider className="bg-card">
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<SidebarInset className="bg-card">
|
<SidebarInset className="bg-card">
|
||||||
{/* Renders nothing unless this page is served through a portal.
|
{/* Draws nothing itself, and nothing at all unless this page is
|
||||||
Inside the inset so it centres over the content, not the window. */}
|
served through a portal: it raises the offline notification. */}
|
||||||
<ConnectionBanner />
|
<ConnectionNotice />
|
||||||
{/* The sidebar carries its own collapse control; a phone has no
|
{/* The sidebar carries its own collapse control; a phone has no
|
||||||
sidebar on screen to carry it. */}
|
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">
|
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 px-4 md:hidden">
|
||||||
|
|||||||
Reference in New Issue
Block a user