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
+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.
*