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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YrQnKV3bnQd4K342y8tKj
This commit is contained in:
2026-08-31 10:24:26 +02:00
co-authored by Claude Opus 5
parent d01a8dad37
commit 954d90fbf7
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>
+26 -18
View File
@@ -22,7 +22,7 @@ import { cn } from "@/lib/utils"
import { qualify } from "./deriveEdges"
import { useLiveValue } from "./liveStore"
import { asText, DTYPES } from "./NodePanel"
import { PANEL_SECTION } from "./SidePanel"
import { InfoTip, PANEL_SECTION } from "./SidePanel"
import { ValuePreview } from "./ValuePreview"
/**
@@ -274,12 +274,17 @@ export function BoundarySections({
return (
<>
<div className="grid gap-3">
<span className={PANEL_SECTION}>Mode</span>
<div className="flex items-center gap-1">
<span className={PANEL_SECTION}>Mode</span>
<InfoTip label="Mode">
A live flow runs continuously, with its subscriptions, schedules and
webhooks active. A batch flow runs only when a run asks it to, from
its inputs to its outputs.
</InfoTip>
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{batch
? "Runs when a run asks it to, from its inputs to its outputs. Nothing here is subscribed or scheduled."
: "Runs continuously: subscriptions, schedules and webhooks are live."}
{batch ? "Batch" : "Live"}
</p>
<Select
value={definition.mode ?? "live"}
@@ -301,6 +306,10 @@ export function BoundarySections({
<div className="grid gap-3">
<div className="flex items-center justify-between">
<span className={PANEL_SECTION}>Inputs</span>
<InfoTip label="Inputs">
Messages that arrive from outside, such as a dashboard control, a
run or the API, and the value the flow starts from.
</InfoTip>
<Button
variant="ghost"
size="sm"
@@ -315,13 +324,6 @@ export function BoundarySections({
</Button>
</div>
{inputs.length === 0 ? (
<p className="text-sm text-muted-foreground">
Messages that arrive from outside a dashboard control, a run, the
API and the value the flow starts from.
</p>
) : null}
{inputs.map((declared, index) => (
// Keyed by position: renaming an input must not remount its row.
<InputRow
@@ -341,12 +343,18 @@ export function BoundarySections({
{batch ? (
<div className="grid gap-3">
<span className={PANEL_SECTION}>Result</span>
<p className="text-sm text-muted-foreground">
{provided.length === 0
? "Nothing is produced here yet, so a run has nothing to report."
: "What a run reports when it finishes. Everything else it computed goes with it."}
</p>
<div className="flex items-center gap-1">
<span className={PANEL_SECTION}>Result</span>
<InfoTip label="Result">
What a run reports when it finishes. Everything else it computed
goes with it.
</InfoTip>
</div>
{provided.length === 0 ? (
<p className="text-sm text-muted-foreground">
Nothing is produced here yet, so a run has nothing to report.
</p>
) : null}
{provided.map((name) => (
<label
key={name}
+28 -18
View File
@@ -12,7 +12,7 @@ import {
} from "@/components/ui/dialog"
import { Switch } from "@/components/ui/switch"
import { BoundarySections } from "./FlowBoundary"
import { PANEL_SECTION, PanelTitle, SidePanel } from "./SidePanel"
import { PanelSection, PanelTitle, SidePanel } from "./SidePanel"
/**
* The flow's own settings, in the same panel its nodes use — down to where the
@@ -72,13 +72,14 @@ export function FlowPanel({
}
>
<div className="grid gap-6 p-4">
<div className="grid gap-3">
<span className={PANEL_SECTION}>Running</span>
<PanelSection
title="Running"
className="gap-3"
help="While this is on, the engine keeps the flow's subscriptions, schedules and webhooks live. Off, none of it is subscribed, scheduled or reachable."
>
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{enabled
? "The engine runs this flow: subscriptions, schedules and webhooks are live."
: "Stopped. Nothing of this flow is subscribed, scheduled or reachable."}
{enabled ? "Live" : "Stopped"}
</p>
<Switch
checked={enabled}
@@ -88,7 +89,7 @@ export function FlowPanel({
data-testid="flow-enabled"
/>
</div>
</div>
</PanelSection>
<BoundarySections
flow={definition.name}
@@ -97,25 +98,34 @@ export function FlowPanel({
onChange={onChange}
/>
<div className="grid gap-3">
<span className={PANEL_SECTION}>Contents</span>
<PanelSection
title="Contents"
className="gap-3"
help={
<>
Every message in this flow is named under{" "}
<span className="font-mono">{definition.name}</span>, so two
flows can use the same short name without colliding.
</>
}
>
<p className="text-sm text-muted-foreground">
<span className="font-mono">{definition.name}</span> namespaces
every message in here.{" "}
{nodeCount === 0
? "No nodes yet."
: `${nodeCount} node${nodeCount === 1 ? "" : "s"}.`}
: `${nodeCount} node${nodeCount === 1 ? "" : "s"}`}
</p>
</div>
</PanelSection>
{hasDraft ? (
<div className="grid gap-3">
<span className={PANEL_SECTION}>Unpublished changes</span>
<PanelSection
title="Unpublished changes"
className="gap-3"
help="Edits are kept as a draft until you publish them. The bar below publishes this one, or discards it."
>
<p className="text-sm text-muted-foreground">
The engine is still running the last published version of this
flow. The bar below publishes it, or throws the edit away.
The engine is still running the last published version.
</p>
</div>
</PanelSection>
) : null}
</div>
</SidePanel>
+37 -30
View File
@@ -49,7 +49,7 @@ import {
secretsQueryOptions,
useParamSuggestions,
} from "./queries"
import { PANEL_SECTION, PanelTitle, SidePanel } from "./SidePanel"
import { InfoTip, PANEL_SECTION, PanelTitle, SidePanel } from "./SidePanel"
const NodeEditor = lazy(() => import("./NodeEditor"))
@@ -368,8 +368,8 @@ function PortList({
aria-label="Streaming output"
aria-pressed={spec.stream ?? false}
title={
"Published repeatedly while the node runs a curve rather " +
"than a result. A run keeps every value it takes."
"Published repeatedly while the node runs: a curve rather than " +
"a result. A run keeps every value it takes."
}
className={cn(
"shrink-0",
@@ -613,15 +613,15 @@ function CronHelp({
return (
<>
<p className="text-xs text-muted-foreground">
Five fields {" "}
<InfoTip label="Cron">
Five fields:{" "}
<span className="font-mono">
minute hour day-of-month month weekday
</span>
. <span className="font-mono">*</span> is every,{" "}
<span className="font-mono">*/5</span> every fifth,{" "}
<span className="font-mono">1-5</span> a range.
</p>
</InfoTip>
{derivable && derived && params.cron !== derived ? (
<Button
variant="ghost"
@@ -886,9 +886,17 @@ function ResourcesSection({
return (
<div className="grid gap-1.5">
<Label htmlFor="node-resources" className={SECTION}>
Resources
</Label>
<div className="flex items-center gap-1">
<Label htmlFor="node-resources" className={SECTION}>
Resources
</Label>
<InfoTip label="Resources">
What is held for the whole execution, and what the node's own
libraries are told they may use. The duration is a planning figure,
not a limit. Held nothing, the node shares the pool with every other
node that asks for nothing.
</InfoTip>
</div>
<Select value={choice} onValueChange={pick}>
<SelectTrigger id="node-resources" className="h-8 text-sm">
<SelectValue />
@@ -946,7 +954,7 @@ function ResourcesSection({
{resources ? (
<Input
className="h-8 text-sm"
placeholder="expected duration 30m, 2h"
placeholder="expected duration, e.g. 30m or 2h"
defaultValue={formatDuration(resources.duration_s)}
key={`${choice}-${resources.duration_s ?? ""}`}
onBlur={(event) => {
@@ -962,12 +970,6 @@ function ResourcesSection({
}}
/>
) : null}
<p className="text-xs text-muted-foreground">
{resources
? "Held for the whole execution, and what the node's own libraries are told they may use. The duration is a planning fact, not a limit."
: "Nothing held: the node shares the pool with every other node that says nothing."}
</p>
</div>
)
}
@@ -1047,10 +1049,13 @@ function SharingSection({
return (
<div className="grid gap-3">
<span className={SECTION}>Reuse</span>
<p className="text-sm text-muted-foreground">
Move this node's code to the library so other flows can run it too.
</p>
<div className="flex items-center gap-1">
<span className={SECTION}>Reuse</span>
<InfoTip label="Reuse">
Moves this node's code to the library so other flows can run it too.
Editing a shared node changes it everywhere at once.
</InfoTip>
</div>
<div className="flex items-center gap-1.5">
<Input
value={name}
@@ -1325,9 +1330,16 @@ function PanelBody({
) : null}
{hasSource ? (
<div className="grid gap-1.5">
<Label htmlFor="node-timeout" className={SECTION}>
Timeout
</Label>
<div className="flex items-center gap-1">
<Label htmlFor="node-timeout" className={SECTION}>
Timeout
</Label>
<InfoTip label="Timeout">
Seconds this code may stay silent before it is stopped. A yield
or an emit resets the clock. 0 disables the limit; empty uses
the engine default.
</InfoTip>
</div>
<Input
id="node-timeout"
type="number"
@@ -1346,11 +1358,6 @@ function PanelBody({
})
}
/>
<p className="text-xs text-muted-foreground">
Seconds this code may be silent before it is stopped a yield or
an emit resets the clock. 0 disables the limit; empty uses the
engine default.
</p>
</div>
) : null}
{hasSource ? (
@@ -1394,8 +1401,8 @@ function PanelBody({
data-testid="node-source-generated"
>
Generated by <span className="font-mono">fluksio sync</span> from{" "}
<span className="font-mono">{origin.repo || "a repository"}</span>{" "}
edit it there and sync again.
<span className="font-mono">{origin.repo || "a repository"}</span>
. Edit it there and sync again.
</p>
) : null}
<div className="min-h-0 flex-1 overflow-hidden rounded-md border border-border">
+95 -1
View File
@@ -1,11 +1,17 @@
import { X } from "lucide-react"
import { Info, X } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import type { ReactNode } from "react"
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useIsMobile } from "@/hooks/useMobile"
import { duration, easeEmphasized, easeStandard } from "@/lib/motion"
import { cn } from "@/lib/utils"
@@ -28,6 +34,94 @@ const panelSlide = {
export const PANEL_SECTION =
"text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground"
/**
* The explanation behind a caption, one tap or one hover away.
*
* Panels label their controls and put the rest here, so a panel reads as a
* list of settings rather than as a page of prose. Keep it to a sentence or
* two: a tooltip nobody can scroll is the wrong home for more.
*
* `open` is held here because a Radix tooltip never opens on touch — it
* ignores a touch pointer entirely — and below `md` the whole panel is a
* full-screen sheet, so a phone would get the caption and nothing else.
* Clicking toggles it; hover still drives it on a pointer device.
*/
export function InfoTip({
label,
children,
}: {
label: string
children: ReactNode
}) {
const [open, setOpen] = useState(false)
return (
<Tooltip open={open} onOpenChange={setOpen}>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`About ${label}`}
className="inline-flex size-11 shrink-0 items-center justify-center text-muted-foreground md:size-5"
onClick={() => setOpen((was) => !was)}
>
<Info className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-xs text-balance">
{children}
</TooltipContent>
</Tooltip>
)
}
/**
* A field's label, with its explanation behind an `InfoTip` rather than in a
* paragraph under the control. Sits inside the usual `grid gap-1.5` block.
*/
export function FieldLabel({
children,
help,
htmlFor,
}: {
children: string
help?: ReactNode
htmlFor?: string
}) {
return (
<div className="flex items-center gap-1">
<Label htmlFor={htmlFor} className="text-sm font-normal">
{children}
</Label>
{help ? <InfoTip label={children}>{help}</InfoTip> : null}
</div>
)
}
/**
* A captioned block in a panel: the caption, its explanation behind an
* `InfoTip`, and the controls under both.
*/
export function PanelSection({
title,
help,
className,
children,
}: {
title: string
help?: ReactNode
className?: string
children: ReactNode
}) {
return (
<div className={cn("grid gap-2", className)}>
<div className="flex items-center gap-1">
<span className={PANEL_SECTION}>{title}</span>
{help ? <InfoTip label={title}>{help}</InfoTip> : null}
</div>
{children}
</div>
)
}
/**
* What the thing in this panel is called, and the one place to rename it.
*