Move panel explanations into tooltips

The node, flow, widget and dashboard panels explained themselves in
paragraphs under every control. They now carry a caption and an info
tooltip, so a panel reads as a list of settings.

InfoTip, PanelSection and FieldLabel live beside PANEL_SECTION in
SidePanel.tsx, which the panels already share. InfoTip holds its own open
state because a Radix tooltip ignores a touch pointer, and below md the
whole panel is a full-screen sheet — hover-only help would leave a phone
with the caption and nothing else.

Dashboard/panels.tsx drops from 30 helper paragraphs to 5; the ones left
are empty states and status, not explanation. Em-dashes are out of the
user-facing strings, except where one separates the halves of a select
item.
This commit is contained in:
2026-08-31 10:24:26 +02:00
parent 6c8a101d24
commit 4e98bf30bf
6 changed files with 303 additions and 224 deletions
@@ -15,6 +15,7 @@ import {
useSavePanels,
useUnpairPanel,
} from "@/components/Dashboard/queries"
import { InfoTip } from "@/components/Flow/SidePanel"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
@@ -98,9 +99,7 @@ export function PanelsDialog() {
<DialogTitle>Panels</DialogTitle>
<DialogDescription>
A panel is one screen and the dashboards it shows. Point the device at
a link and it asks for a code you enter here — this instance's own
address for a screen on your network, or the portal's for one hanging
where this machine is not reachable.
a link and it asks for a code you enter here.
</DialogDescription>
</DialogHeader>
@@ -232,9 +231,7 @@ function PanelRow({
}),
onSuccess: () => {
setCode("")
showSuccessToast(
"Paired — the screen switches over within a few seconds.",
)
showSuccessToast("Paired. The screen switches over within a few seconds.")
},
onError: handleError.bind(showErrorToast),
})
@@ -254,7 +251,7 @@ function PanelRow({
// The trash icon carries no text of its own, so the confirm step says what
// the next press does through the label a reader or a hover gets.
const removeLabel = confirmRemove
? `Confirm — remove ${panel.id}`
? `Confirm: remove ${panel.id}`
: `Remove ${panel.id}`
return (
@@ -405,7 +402,7 @@ function PanelRow({
unpair.mutate(undefined, {
onSuccess: () =>
showSuccessToast(
"Unpaired — the screen asks for a new code.",
"Unpaired. The screen asks for a new code.",
),
onError: (error) =>
handleError.call(showErrorToast, error as ApiError),
@@ -413,16 +410,16 @@ function PanelRow({
}}
>
{confirmUnpair
? "Confirm — sign this screen out"
? "Confirm: sign this screen out"
: "Unpair screen"}
</Button>
<p className="min-w-0 flex-1 text-xs text-muted-foreground">
Sends the screen hanging here back to a pairing code and keeps
the panel, its dashboards and their arrangement — unlike
removing the panel, which throws all three away. A screen paired
through the portal holds a credential this does not reach; that
one is revoked at the portal.
</p>
<InfoTip label="Unpair screen">
Sends the screen hanging here back to a pairing code, keeping
the panel, its dashboards and their arrangement. Removing the
panel throws all three away instead. A screen paired through the
portal holds a credential this does not reach; revoke that one
there.
</InfoTip>
</div>
</>
) : null}
+104 -141
View File
@@ -5,7 +5,10 @@ import { useState } from "react"
import type { MessageInfo, SettingDef, WidgetDef } from "@/client"
import { DEFAULT_RANGE, RANGES } from "@/components/Common/RangePicker"
import {
FieldLabel,
InfoTip,
PANEL_SECTION,
PanelSection,
PanelTitle,
SidePanel,
} from "@/components/Flow/SidePanel"
@@ -544,16 +547,16 @@ export function WidgetPanel({
<Label className="text-sm font-normal" htmlFor="embed-url">
Address
</Label>
<InfoTip label="Address">
Any page that allows being framed. A site that refuses shows its
own refusal in the tile.
</InfoTip>
<Input
id="embed-url"
value={str(cfg.url)}
placeholder="https://embed.ventusky.com/?p=49.41;8.93;9"
onChange={(event) => set({ url: event.target.value })}
/>
<p className="text-xs text-muted-foreground">
Any page that allows being framed. Sites that refuse show their
own refusal in the tile.
</p>
</div>
) : widget.type === "markdown" ? (
<div className="grid gap-1.5">
@@ -827,7 +830,9 @@ export function WidgetPanel({
{widget.type === "chart" && !querying ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Points kept</Label>
<FieldLabel help="How much past the engine keeps for these messages.">
Points kept
</FieldLabel>
<Input
type="number"
value={str(
@@ -837,16 +842,17 @@ export function WidgetPanel({
set({ history: { points: Number(event.target.value) || 0 } })
}
/>
<p className="text-xs text-muted-foreground">
How much past the engine keeps for these messages.
</p>
</div>
) : null}
{widget.type === "chart" && querying ? (
<>
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Refresh, seconds</Label>
<FieldLabel
help={`Leave empty to follow the window: ${paced} seconds at this range, since nothing changes until the bucket closes. A slower value is kept; a faster one only asks for the same picture twice.`}
>
Refresh, seconds
</FieldLabel>
<Input
type="number"
min={paced}
@@ -861,14 +867,11 @@ export function WidgetPanel({
})
}
/>
<p className="text-xs text-muted-foreground">
Empty follows the window — {paced} seconds at this range, since
nothing changes until the bucket closes. A slower one is kept, a
faster one only asks for the same picture twice.
</p>
</div>
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Range shown first</Label>
<FieldLabel help="A viewer can pick another on the widget itself.">
Range shown first
</FieldLabel>
<Select
value={str(cfg.range_s ?? DEFAULT_RANGE.hours * 3600)}
onValueChange={(value) => set({ range_s: Number(value) })}
@@ -887,9 +890,6 @@ export function WidgetPanel({
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
A viewer can pick another on the widget itself.
</p>
</div>
</>
) : null}
@@ -956,7 +956,9 @@ export function WidgetPanel({
{widget.type === "chart" ? (
<div className="grid gap-1.5">
<div className="flex items-center justify-between gap-2 text-sm">
Smoothing
<FieldLabel help="Curves the lines between readings. It draws them softer; it does not change what was measured.">
Smoothing
</FieldLabel>
<Switch
checked={Boolean(cfg.smooth)}
aria-label="Smoothing"
@@ -964,10 +966,6 @@ export function WidgetPanel({
onCheckedChange={(smooth) => set({ smooth })}
/>
</div>
<p className="text-xs text-muted-foreground">
Curves the lines between readings. It draws them softer; it does
not change what was measured.
</p>
</div>
) : null}
@@ -1029,7 +1027,9 @@ export function WidgetPanel({
{widget.type === "slider" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Drawn as</Label>
<FieldLabel help="A fader takes the tile's height, so give it a tall one. Either way the scale is dropped, since a column has no room for the labels.">
Drawn as
</FieldLabel>
<Segmented
value={str(cfg.orientation) || "horizontal"}
options={[
@@ -1040,10 +1040,6 @@ export function WidgetPanel({
testId="widget-orientation"
onChange={(orientation) => set({ orientation })}
/>
<p className="text-xs text-muted-foreground">
A fader takes the tile's height, so give it a tall one. Its scale
is dropped either way: a column has no room for the labels.
</p>
</div>
) : null}
@@ -1126,6 +1122,11 @@ export function WidgetPanel({
{widget.type === "icon" ? (
<div className="grid gap-2">
<span className={PANEL_SECTION}>Mapping</span>
<InfoTip label="Mapping">
Rows are checked top to bottom and the first match wins. A number
also matches anything above it, so a descending ladder reads as
thresholds.
</InfoTip>
{rules.map((rule, index) => (
<div
// Position is the only identity a rule row has.
@@ -1219,11 +1220,6 @@ export function WidgetPanel({
<Plus />
Add value
</Button>
<p className="text-xs text-muted-foreground">
Rows are checked top to bottom and the first match wins. A number
also matches anything above it, so a descending ladder reads as
thresholds.
</p>
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Otherwise</Label>
<IconPicker
@@ -1239,7 +1235,18 @@ export function WidgetPanel({
{widget.type === "color" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Sends</Label>
<FieldLabel
help={
<>
HSV is{" "}
<span className="font-mono">[h 0-360, s 0-100, v 0-100]</span>
, RGB <span className="font-mono">[r, g, b]</span> 0-255, Hex{" "}
<span className="font-mono">"#rrggbb"</span>.
</>
}
>
Sends
</FieldLabel>
<Segmented
value={colorFormatOf(widget)}
options={COLOR_FORMATS}
@@ -1258,12 +1265,6 @@ export function WidgetPanel({
})
}
/>
<p className="text-xs text-muted-foreground">
HSV is{" "}
<span className="font-mono">[h 0-360, s 0-100, v 0-100]</span>,
RGB <span className="font-mono">[r, g, b]</span> 0-255, Hex{" "}
<span className="font-mono">"#rrggbb"</span>.
</p>
</div>
) : null}
@@ -1283,23 +1284,23 @@ export function WidgetPanel({
/>
</div>
<div className="grid gap-1.5">
<Label className="text-sm font-normal" htmlFor="media-stream">
<FieldLabel
htmlFor="media-stream"
help="A camera's own stream, played straight from it. Messages carry one frame at a time, which suits a glance every few seconds rather than live video."
>
Live stream
</Label>
</FieldLabel>
<Input
id="media-stream"
value={str(cfg.stream_url)}
placeholder="https://camera.local/stream.mjpg"
onChange={(event) => set({ stream_url: event.target.value })}
/>
<p className="text-xs text-muted-foreground">
A camera's own stream, played straight from it. Messages carry a
frame at a time, which suits a glance every few seconds rather
than live video.
</p>
</div>
<div className="flex items-center justify-between gap-2 text-sm">
Play as it arrives
<FieldLabel help="A browser only plays sound by itself once someone has touched the page, so a screen nobody has tapped stays silent.">
Play as it arrives
</FieldLabel>
<Switch
checked={Boolean(cfg.autoplay)}
aria-label="Play as it arrives"
@@ -1307,10 +1308,6 @@ export function WidgetPanel({
onCheckedChange={(autoplay) => set({ autoplay })}
/>
</div>
<p className="text-xs text-muted-foreground">
A browser only plays sound by itself once someone has touched the
page, so a screen nobody has tapped stays silent.
</p>
</div>
) : null}
@@ -1408,7 +1405,7 @@ function SettingBinding({
value={str(setting.message)}
label="Driven by"
testId={`dashboard-${name}-message`}
placeholder={`Nothing — always ${valueLabel(name, setting.value)}`}
placeholder={`Not bound, always ${valueLabel(name, setting.value)}`}
filter={(message) => message.dtype === want}
onPick={(message, dtype) =>
onChange({ ...setting, message, dtype })
@@ -1473,7 +1470,7 @@ function PalettePicker({
/>
{palette.length === 0 ? (
<p className="text-sm text-muted-foreground">
No palette — this dashboard keeps the app's own colours.
No palette. This dashboard keeps the app's own colours.
</p>
) : (
<div
@@ -1600,8 +1597,10 @@ export function DashboardPanel({
}
>
<div className="grid gap-5 p-4">
<div className="grid gap-2">
<span className={PANEL_SECTION}>Rail icon</span>
<PanelSection
title="Rail icon"
help="Drawn on the rail when a panel carries more than one dashboard."
>
<IconPicker
value={str(dashboard.icon)}
placeholder="Two letters of the title"
@@ -1610,13 +1609,12 @@ export function DashboardPanel({
className="w-full"
onChange={(icon) => onChange({ icon })}
/>
<p className="text-sm text-muted-foreground">
Drawn on the rail when a panel carries more than one dashboard.
</p>
</div>
</PanelSection>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Grid</span>
<PanelSection
title="Grid"
help="How many columns wide the layout is. Match it to the panel this will hang on."
>
<Select
value={String(columnsOf(dashboard))}
onValueChange={(value) => onChange({ columns: Number(value) })}
@@ -1632,14 +1630,12 @@ export function DashboardPanel({
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
How many columns wide this dashboard is laid out, so it can be
matched to the panel it will hang on.
</p>
</div>
</PanelSection>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Canvas</span>
<PanelSection
title="Canvas"
help="The size of the panel this is drawn for, in pixels. Editing and viewing both scale it to fit, so the arrangement looks the same everywhere."
>
<Select
value={
CANVAS_PRESETS.some(
@@ -1690,15 +1686,12 @@ export function DashboardPanel({
}
/>
</div>
<p className="text-sm text-muted-foreground">
The panel this is drawn for, in pixels. Editing and viewing both
scale that surface to fit, so the arrangement is the same
everywhere.
</p>
</div>
</PanelSection>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Look</span>
<PanelSection
title="Look"
help="How the dashboard is drawn. Fluksio is the app's own design; Material lays flat tonal cards on a plain ground; Glass floats translucent tiles over a moving one. A look changes how widgets look, never what they do."
>
<Segmented
value={lookOf(look.value)}
options={LOOK_CHOICES}
@@ -1711,18 +1704,12 @@ export function DashboardPanel({
setting={look}
onChange={(setting) => setSetting("look", setting)}
/>
<p className="text-sm text-muted-foreground">
How this dashboard is drawn. Fluksio is the app's own design, and
what a dashboard wears until you choose otherwise; Material lays
flat tonal cards on a plain ground; Glass floats translucent tiles
over a soft moving one. The widgets are the same whichever you
pick — a look changes what they look like and nothing about what
they do.
</p>
</div>
</PanelSection>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Theme</span>
<PanelSection
title="Theme"
help="Light or dark wherever this is shown. System follows the device, which a wall screen has nobody to set. Bind a message to let a flow drive it on a schedule; this choice stays the fallback. A palette overrides it, since its first colour is the ground."
>
<Segmented
value={str(theme.value) || "system"}
options={THEME_CHOICES}
@@ -1735,19 +1722,12 @@ export function DashboardPanel({
setting={theme}
onChange={(setting) => setSetting("theme", setting)}
/>
<p className="text-sm text-muted-foreground">
What this dashboard wears wherever it is shown — a screen on a
wall has nobody to set the device preference System otherwise
follows. Bind a message and a flow drives it instead: a node
publishing on a cron is what a schedule looks like here, and the
choice above stays the fallback. A palette settles this for
itself: its first colour is the ground, so a dashboard that names
one is already light or dark and this is left idle.
</p>
</div>
</PanelSection>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Palette</span>
<PanelSection
title="Palette"
help="Colours in order: ground, widget surface, primary, accent, text. Leave the later ones off and they are derived, so three is enough for a whole dashboard. A sixth and beyond are chart line colours. Name none to keep the app's own."
>
<PalettePicker
palette={palette}
onChange={(value: string[]) =>
@@ -1759,18 +1739,12 @@ export function DashboardPanel({
setting={paletteSetting}
onChange={(setting) => setSetting("palette", setting)}
/>
<p className="text-sm text-muted-foreground">
The colours this dashboard is drawn in, in order: the ground, the
surface a widget is, the primary, the accent, and the text. Leave
the later ones off and they are worked out from the ones you gave
— three colours are a whole dashboard. Anything past the five is
another colour for a chart to draw a line in. Name none and the
dashboard keeps the app's own.
</p>
</div>
</PanelSection>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Background</span>
<PanelSection
title="Background"
help="An image drawn under the widgets, covering the canvas. It replaces the ground the Glass look brings. Bind a message to let a flow pick the picture."
>
<Input
type="url"
placeholder="https://…"
@@ -1789,16 +1763,12 @@ export function DashboardPanel({
setting={background}
onChange={(setting) => setSetting("background", setting)}
/>
<p className="text-sm text-muted-foreground">
An image drawn under the widgets, covering the canvas. It takes
the place of the ground the Glass look brings with it. Bind a
message and a flow decides the picture — one per season, or one
per time of day.
</p>
</div>
</PanelSection>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Touch</span>
<PanelSection
title="Touch"
help="Bigger controls, and nothing that only happens on hover, for a panel that is touched rather than pointed at. A phone gets this anyway; a wall panel cannot say so for itself."
>
<div className="flex items-center justify-between gap-2 text-sm">
Touch friendly
<Switch
@@ -1815,15 +1785,12 @@ export function DashboardPanel({
setting={touch}
onChange={(setting) => setSetting("touch", setting)}
/>
<p className="text-sm text-muted-foreground">
Bigger controls, and nothing that only happens on hover — for a
panel that is touched rather than pointed at. A phone gets this
anyway; a wall panel has no way to say so for itself.
</p>
</div>
</PanelSection>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Lock</span>
<PanelSection
title="Lock"
help="Locked, the controls are still shown but stop publishing, and the surface says so. This is a read-only surface, not a permission: what a paired screen may reach is decided by its own credential."
>
<div className="flex items-center justify-between gap-2 text-sm">
Read-only
<Switch
@@ -1840,23 +1807,19 @@ export function DashboardPanel({
setting={locked}
onChange={(setting) => setSetting("locked", setting)}
/>
<p className="text-sm text-muted-foreground">
Locked, the controls on this dashboard are shown but stop
publishing, and the surface says so. It is a read-only surface
rather than a permission: what a paired screen may reach is still
decided by its own credential.
</p>
</div>
</PanelSection>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Contents</span>
<PanelSection
title="Contents"
help="What this dashboard is called in the API, in a panel's list and in a link to it."
>
<p className="text-sm text-muted-foreground">
<span className="font-mono">{dashboard.name}</span> —{" "}
<span className="font-mono">{dashboard.name}</span>,{" "}
{widgetCount === 0
? "nothing on it yet."
: `${widgetCount} widget${widgetCount === 1 ? "" : "s"}.`}
? "nothing on it yet"
: `${widgetCount} widget${widgetCount === 1 ? "" : "s"}`}
</p>
</div>
</PanelSection>
</div>
</SidePanel>