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
|
||||
* 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.
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user