From 954d90fbf75219a404a1e5f444c271ebe05a12ca Mon Sep 17 00:00:00 2001 From: stroblme Date: Mon, 31 Aug 2026 10:24:26 +0200 Subject: [PATCH] Move panel explanations into tooltips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_015YrQnKV3bnQd4K342y8tKj --- .../src/components/Dashboard/PanelsDialog.tsx | 29 +-- frontend/src/components/Dashboard/panels.tsx | 245 ++++++++---------- frontend/src/components/Flow/FlowBoundary.tsx | 44 ++-- frontend/src/components/Flow/FlowPanel.tsx | 46 ++-- frontend/src/components/Flow/NodePanel.tsx | 67 ++--- frontend/src/components/Flow/SidePanel.tsx | 96 ++++++- 6 files changed, 303 insertions(+), 224 deletions(-) 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. -

) : widget.type === "markdown" ? (
@@ -827,7 +830,9 @@ export function WidgetPanel({ {widget.type === "chart" && !querying ? (
- + + Points kept + -

- How much past the engine keeps for these messages. -

) : null} {widget.type === "chart" && querying ? ( <>
- + + Refresh, seconds + -

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

) : null} @@ -956,7 +956,9 @@ export function WidgetPanel({ {widget.type === "chart" ? (
- Smoothing + + Smoothing + set({ smooth })} />
-

- Curves the lines between readings. It draws them softer; it does - not change what was measured. -

) : null} @@ -1029,7 +1027,9 @@ export function WidgetPanel({ {widget.type === "slider" ? (
- + + Drawn as + set({ orientation })} /> -

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

) : null} @@ -1126,6 +1122,11 @@ export function WidgetPanel({ {widget.type === "icon" ? (
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. -

- + + HSV is{" "} + [h 0-360, s 0-100, v 0-100] + , RGB [r, g, b] 0-255, Hex{" "} + "#rrggbb". + + } + > + Sends + -

- HSV is{" "} - [h 0-360, s 0-100, v 0-100], - RGB [r, g, b] 0-255, Hex{" "} - "#rrggbb". -

) : null} @@ -1283,23 +1284,23 @@ export function WidgetPanel({ />
- + set({ stream_url: event.target.value })} /> -

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

) : 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 ? (

- 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 + setSetting("background", setting)} /> -

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

-
+
-
- Contents +

- {dashboard.name} —{" "} + {dashboard.name},{" "} {widgetCount === 0 - ? "nothing on it yet." - : `${widgetCount} widget${widgetCount === 1 ? "" : "s"}.`} + ? "nothing on it yet" + : `${widgetCount} widget${widgetCount === 1 ? "" : "s"}`}

-
+
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"}

@@ -946,7 +954,7 @@ function ResourcesSection({ {resources ? ( { @@ -962,12 +970,6 @@ function ResourcesSection({ }} /> ) : null} - -

- {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."} -

) } @@ -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. *