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:
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
<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">
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
<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">
|
||||
<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">
|
||||
{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."}
|
||||
Nothing is produced here yet, so a run has nothing to report.
|
||||
</p>
|
||||
) : null}
|
||||
{provided.map((name) => (
|
||||
<label
|
||||
key={name}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
<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">
|
||||
<div className="flex items-center gap-1">
|
||||
<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>
|
||||
<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">
|
||||
<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">
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user