diff --git a/frontend/src/components/Dashboard/PanelsDialog.tsx b/frontend/src/components/Dashboard/PanelsDialog.tsx
index 9bf3bd8..ea46f2a 100644
--- a/frontend/src/components/Dashboard/PanelsDialog.tsx
+++ b/frontend/src/components/Dashboard/PanelsDialog.tsx
@@ -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() {
Panels
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.
@@ -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"}
-
- 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.
-
+
+ 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.
+
>
) : null}
diff --git a/frontend/src/components/Dashboard/panels.tsx b/frontend/src/components/Dashboard/panels.tsx
index 276b405..1c03ef7 100644
--- a/frontend/src/components/Dashboard/panels.tsx
+++ b/frontend/src/components/Dashboard/panels.tsx
@@ -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({
+
+ Any page that allows being framed. A site that refuses shows its
+ own refusal in the tile.
+
set({ url: event.target.value })}
/>
-
- Any page that allows being framed. Sites that refuse show their
- own refusal in the tile.
-
- 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.
-
-
+
+ Range shown first
+
-
- A viewer can pick another on the widget itself.
-
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.
+
{rules.map((rule, index) => (
Add value
-
- 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.
-
- 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.
-
- Play as it arrives
+
+ Play as it arrives
+ set({ autoplay })}
/>
-
- A browser only plays sound by itself once someone has touched the
- page, so a screen nobody has tapped stays silent.
-
- No palette — this dashboard keeps the app's own colours.
+ No palette. This dashboard keeps the app's own colours.
) : (
-
- Rail icon
+ onChange({ icon })}
/>
-
- Drawn on the rail when a panel carries more than one dashboard.
-
-
+
-
- Grid
+
-
- How many columns wide this dashboard is laid out, so it can be
- matched to the panel it will hang on.
-
-
+
-
- Canvas
+
-
- The panel this is drawn for, in pixels. Editing and viewing both
- scale that surface to fit, so the arrangement is the same
- everywhere.
-
-
+
-
- Look
+ setSetting("look", setting)}
/>
-
- 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.
-
-
+
-
- Theme
+ setSetting("theme", setting)}
/>
-
- 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.
-
- 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.
-
- 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.
-
-
+
-
- Touch
+
Touch friendly
setSetting("touch", setting)}
/>
-
- 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.
-
-
+
-
- Lock
+
Read-only
setSetting("locked", setting)}
/>
-
- 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.
-
diff --git a/frontend/src/components/Flow/FlowBoundary.tsx b/frontend/src/components/Flow/FlowBoundary.tsx
index cb2116a..4dedcde 100644
--- a/frontend/src/components/Flow/FlowBoundary.tsx
+++ b/frontend/src/components/Flow/FlowBoundary.tsx
@@ -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 (
<>
- Mode
+
+ 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.
+
+
- {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"}
)
}
@@ -1047,10 +1049,13 @@ function SharingSection({
return (
- Reuse
-
- Move this node's code to the library so other flows can run it too.
-
+
+ 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.
+
+
-
+
+
+
+ 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.
+
+
-
- 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.
-
) : null}
{hasSource ? (
@@ -1394,8 +1401,8 @@ function PanelBody({
data-testid="node-source-generated"
>
Generated by fluksio sync from{" "}
- {origin.repo || "a repository"}{" "}
- — edit it there and sync again.
+ {origin.repo || "a repository"}
+ . Edit it there and sync again.
) : null}
diff --git a/frontend/src/components/Flow/SidePanel.tsx b/frontend/src/components/Flow/SidePanel.tsx
index 914b080..8f5b2eb 100644
--- a/frontend/src/components/Flow/SidePanel.tsx
+++ b/frontend/src/components/Flow/SidePanel.tsx
@@ -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 (
+
+
+
+
+
+ {children}
+
+
+ )
+}
+
+/**
+ * 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 (
+
+
+ {help ? {help} : null}
+
+ )
+}
+
+/**
+ * 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 (
+
+
+ {title}
+ {help ? {help} : null}
+
+ {children}
+
+ )
+}
+
/**
* What the thing in this panel is called, and the one place to rename it.
*