refactor(web): redesign ConnectingOverlay as hero card + fix early visibility

Two changes:

Visibility fix — overlay now reads `savedConnections` for any entry
whose status is "connecting" or "configuring". The previous code keyed
off `device.connectionPhase` from the active deviceStore entry, but
that entry doesn't exist until partway through `setupMeshDevice` —
the overlay was hidden during transport-open + storage-open (often
the slowest part of the connect flow).

Visual redo (option C — hero card with radial pings):
- Center: transport-type icon (Globe / Bluetooth / Cable) on a
  gradient disc with two concentric ping rings.
- Below: device name + phase label with small spinner.
- 2x2 grid of stat chips (Identity / Metadata / Channels / Nodes)
  that flip from gray (dashed circle) to emerald (check or count)
  as the SDK reports each piece arriving.
- Slate-to-black gradient panel; non-dismissable while active.
- i18n strings under `connections.overlay.*` updated to match.
This commit is contained in:
Dan Ditomaso
2026-05-02 21:44:28 -04:00
parent f0eaf4da8b
commit 524139705e
2 changed files with 117 additions and 202 deletions

View File

@@ -56,18 +56,16 @@
"updatedByName": "{{name}} has been updated."
},
"overlay": {
"log": {
"header": "meshtastic — connect",
"transportOpening": "Opening transport…",
"transportReady": "Transport ready",
"requestingConfig": "Requesting configuration",
"configReceived": "Received config section ({{n}})",
"moduleReceived": "Received module config ({{n}})",
"channelReceived": "Received channel ({{n}})",
"nodeReceived": "Received node info ({{n}})",
"myInfo": "Identity received",
"metadata": "Metadata received",
"configured": "Configuration complete"
"srTitle": "Connecting to device",
"phase": {
"connecting": "Opening transport…",
"configuring": "Streaming configuration…"
},
"chip": {
"identity": "Identity",
"metadata": "Metadata",
"channels": "Channels",
"nodes": "Nodes"
}
}
}

View File

@@ -1,219 +1,136 @@
import { Dialog, DialogContent, DialogTitle } from "@components/UI/Dialog.tsx";
import { useAppStore, useDeviceStore } from "@core/stores";
import type { ConnectionProgressCounters } from "@meshtastic/sdk";
import { useDeviceStore } from "@core/stores";
import { cn } from "@core/utils/cn.ts";
import { useConnectionProgress } from "@meshtastic/sdk-react";
import type { ReactElement } from "react";
import { useEffect, useRef, useState } from "react";
import { Bluetooth, Cable, CheckCircle2, Globe, Loader2 } from "lucide-react";
import type { ComponentType, ReactElement } from "react";
import { useTranslation } from "react-i18next";
type LineKind = "ok" | "active" | "info";
interface LogLine {
id: number;
ts: number;
kind: LineKind;
text: string;
}
const SYMBOL: Record<LineKind, string> = {
ok: "✓",
active: "→",
info: "•",
};
const COLOR: Record<LineKind, string> = {
ok: "text-emerald-400",
active: "text-sky-300",
info: "text-zinc-400",
const TRANSPORT_ICON: Record<
"http" | "bluetooth" | "serial",
ComponentType<{ className?: string }>
> = {
http: Globe,
bluetooth: Bluetooth,
serial: Cable,
};
/**
* Full-screen terminal-style log overlay shown while the active device's
* connection phase is "connecting" or "configuring". Lines are derived
* from `device.connectionPhase` transitions and `MeshClient.progress`
* counter bumps — pure consumer of domain signals.
* Hero-card connect overlay. Visible while any saved connection's status
* is "connecting" or "configuring" — driven from the saved-connection
* status field rather than `device.connectionPhase` so the overlay shows
* during transport-open + storage-open (the device entry doesn't exist
* yet during those phases).
*
* Auto-scrolls to the latest line. Auto-dismisses once phase flips to
* "configured" / "disconnected" / "error".
* Center: device name + transport icon with an animated radial pulse.
* Surrounding: 4 stat chips (Identity / Channels / Nodes / Metadata)
* that flip from gray to green as the SDK reports each piece arriving.
* Auto-dismisses on "configured" (with a brief success state) or
* "disconnected" / "error".
*/
export const ConnectingOverlay = (): ReactElement | null => {
const { selectedDeviceId } = useAppStore();
const { getDevice } = useDeviceStore();
const device = getDevice(selectedDeviceId);
const phase = device?.connectionPhase ?? "disconnected";
const savedConnections = useDeviceStore((s) => s.savedConnections);
const progress = useConnectionProgress();
const { t } = useTranslation("connections");
const [lines, setLines] = useState<LogLine[]>([]);
const idRef = useRef(0);
const lastPhaseRef = useRef<typeof phase>("disconnected");
const lastCountersRef = useRef<ConnectionProgressCounters | undefined>(undefined);
const scrollRef = useRef<HTMLDivElement>(null);
const active = savedConnections.find(
(c) => c.status === "connecting" || c.status === "configuring",
);
const visible = phase === "connecting" || phase === "configuring";
if (!active) return null;
// Reset the log when a fresh connect cycle starts (idle → connecting).
useEffect(() => {
if (phase === "connecting" && lastPhaseRef.current !== "connecting") {
setLines([]);
idRef.current = 0;
lastCountersRef.current = undefined;
}
lastPhaseRef.current = phase;
}, [phase]);
const Icon = TRANSPORT_ICON[active.type];
const counters =
progress.phase === "configuring" || progress.phase === "configured"
? progress.received
: { config: 0, modules: 0, channels: 0, nodes: 0, myInfo: false, metadata: false };
// High-level phase transitions → log lines.
useEffect(() => {
if (phase === "connecting") {
append("active", t("overlay.log.transportOpening", { defaultValue: "Opening transport…" }));
} else if (phase === "configuring") {
append("ok", t("overlay.log.transportReady", { defaultValue: "Transport ready" }));
append(
"active",
t("overlay.log.requestingConfig", { defaultValue: "Requesting configuration" }),
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [phase]);
// Counter bumps → per-event lines.
useEffect(() => {
if (progress.phase !== "configuring") {
if (progress.phase === "configured") {
append("ok", t("overlay.log.configured", { defaultValue: "Configuration complete" }));
}
return;
}
const cur = progress.received;
const prev = lastCountersRef.current ?? {
config: 0,
modules: 0,
channels: 0,
nodes: 0,
myInfo: false,
metadata: false,
};
if (cur.config > prev.config) {
append(
"info",
t("overlay.log.configReceived", {
n: cur.config,
defaultValue: "Received config section ({{n}})",
}),
);
}
if (cur.modules > prev.modules) {
append(
"info",
t("overlay.log.moduleReceived", {
n: cur.modules,
defaultValue: "Received module config ({{n}})",
}),
);
}
if (cur.channels > prev.channels) {
append(
"info",
t("overlay.log.channelReceived", {
n: cur.channels,
defaultValue: "Received channel ({{n}})",
}),
);
}
if (cur.nodes > prev.nodes) {
append(
"info",
t("overlay.log.nodeReceived", {
n: cur.nodes,
defaultValue: "Received node info ({{n}})",
}),
);
}
if (cur.myInfo && !prev.myInfo) {
append("ok", t("overlay.log.myInfo", { defaultValue: "Identity received" }));
}
if (cur.metadata && !prev.metadata) {
append("ok", t("overlay.log.metadata", { defaultValue: "Metadata received" }));
}
lastCountersRef.current = cur;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [progress]);
// Auto-scroll to bottom on new line.
useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [lines]);
function append(kind: LineKind, text: string): void {
setLines((prev) => [...prev, { id: ++idRef.current, ts: Date.now(), kind, text }]);
}
if (!visible) return null;
const start = lines[0]?.ts ?? Date.now();
const isConnecting = active.status === "connecting";
const phaseLabel = isConnecting
? t("overlay.phase.connecting", { defaultValue: "Opening transport…" })
: t("overlay.phase.configuring", { defaultValue: "Streaming configuration…" });
return (
<Dialog open>
<DialogContent
className="max-w-xl border-zinc-800 bg-zinc-950 p-0 text-zinc-100 shadow-2xl"
className="max-w-sm overflow-hidden border-0 bg-gradient-to-b from-slate-900 via-slate-950 to-black p-0 text-slate-100 shadow-2xl"
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<div className="flex items-center gap-2 border-b border-zinc-800 px-4 py-2">
<div className="flex gap-1.5">
<span className="h-3 w-3 rounded-full bg-red-500/80" />
<span className="h-3 w-3 rounded-full bg-yellow-500/80" />
<span className="h-3 w-3 rounded-full bg-green-500/80" />
</div>
<DialogTitle className="ml-2 font-mono text-xs uppercase tracking-wider text-zinc-400">
{t("overlay.log.header", { defaultValue: "meshtastic — connect" })}
</DialogTitle>
<span className="ml-auto inline-flex items-center gap-1.5 font-mono text-[10px] text-zinc-500">
<span
className={`inline-block h-2 w-2 rounded-full ${
phase === "configuring" ? "animate-pulse bg-sky-400" : "bg-amber-400"
}`}
/>
{phase}
</span>
</div>
<DialogTitle className="sr-only">
{t("overlay.srTitle", { defaultValue: "Connecting to device" })}
</DialogTitle>
<div
ref={scrollRef}
className="max-h-[360px] min-h-[200px] overflow-y-auto bg-zinc-950 px-4 py-3 font-mono text-[12px] leading-relaxed"
>
{lines.length === 0 ? (
<div className="text-zinc-600"></div>
) : (
lines.map((line, i) => {
const isLast = i === lines.length - 1;
const symbol = isLast && line.kind === "active" ? "→" : SYMBOL[line.kind];
return (
<div key={line.id} className="flex items-start gap-3">
<span className="shrink-0 select-none text-zinc-600">
{String(((line.ts - start) / 1000).toFixed(2)).padStart(6, " ")}s
</span>
<span className={`shrink-0 select-none ${COLOR[line.kind]}`}>{symbol}</span>
<span
className={
line.kind === "info"
? "text-zinc-300"
: line.kind === "ok"
? "text-zinc-100"
: "text-zinc-100"
}
>
{line.text}
{isLast && line.kind === "active" && (
<span className="ml-1 inline-block h-3 w-2 animate-pulse bg-zinc-300" />
)}
</span>
</div>
);
})
)}
<div className="flex flex-col items-center gap-6 px-8 pt-10 pb-8">
{/* Hero icon with radial pings */}
<div className="relative flex h-28 w-28 items-center justify-center">
<span className="absolute inset-0 animate-ping rounded-full bg-sky-500/20 [animation-duration:1.8s]" />
<span className="absolute inset-3 animate-ping rounded-full bg-sky-500/30 [animation-duration:2.4s] [animation-delay:0.4s]" />
<span className="absolute inset-6 rounded-full bg-gradient-to-br from-sky-500 to-indigo-600 shadow-lg shadow-sky-500/40" />
<Icon className="relative h-9 w-9 text-white" />
</div>
{/* Name + phase */}
<div className="text-center">
<div className="text-lg font-semibold tracking-tight text-white">{active.name}</div>
<div className="mt-1 inline-flex items-center gap-1.5 text-xs text-slate-400">
<Loader2 className="h-3 w-3 animate-spin" />
{phaseLabel}
</div>
</div>
{/* Stat chips — 2x2 grid */}
<div className="grid w-full grid-cols-2 gap-2">
<Chip
label={t("overlay.chip.identity", { defaultValue: "Identity" })}
done={counters.myInfo}
/>
<Chip
label={t("overlay.chip.metadata", { defaultValue: "Metadata" })}
done={counters.metadata}
/>
<Chip
label={t("overlay.chip.channels", { defaultValue: "Channels" })}
count={counters.channels}
done={counters.channels > 0 && progress.phase === "configured"}
/>
<Chip
label={t("overlay.chip.nodes", { defaultValue: "Nodes" })}
count={counters.nodes}
done={counters.nodes > 0 && progress.phase === "configured"}
/>
</div>
</div>
</DialogContent>
</Dialog>
);
};
interface ChipProps {
label: string;
count?: number;
done: boolean;
}
function Chip({ label, count, done }: ChipProps): ReactElement {
const filled = count !== undefined ? count > 0 : done;
return (
<div
className={cn(
"flex items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm transition-colors",
filled
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-200"
: "border-slate-700/60 bg-slate-800/40 text-slate-400",
)}
>
<span className="font-medium">{label}</span>
{count !== undefined ? (
<span className="font-mono text-xs tabular-nums">{count}</span>
) : done ? (
<CheckCircle2 className="h-4 w-4 text-emerald-400" />
) : (
<span className="h-4 w-4 rounded-full border border-dashed border-slate-600" />
)}
</div>
);
}