Dashboard chrome: an icon picker, one segmented shape, a rail without bars

- IconPicker replaces the three icon selects (rail icon, icon-widget rule,
  "Otherwise"): the glyphs in a grid, and a button that clears back to none —
  which a Radix SelectItem could never offer.
- ModePicker/StylePicker drop out in favour of a shared ui/Segmented, the same
  sliding-thumb shape RangePicker and the widget-side control already wear.
- PanelRail draws no scrollbars at all: hiding them also takes back the gutter
  a vertical bar claimed from a column exactly as wide as its buttons, which is
  what pushed a horizontal bar under them.
- The panels dialog can re-pair one screen (POST /panels/{id}/unpair) without
  deleting the panel it hangs on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu
This commit is contained in:
2026-08-22 12:16:17 +02:00
co-authored by Claude Opus 5
parent 9bb7b57ee8
commit 48955a6787
5 changed files with 264 additions and 118 deletions
@@ -60,7 +60,14 @@ export function PanelRail({
aria-label="Dashboards on this panel" aria-label="Dashboards on this panel"
data-testid="panel-rail" data-testid="panel-rail"
className={cn( className={cn(
"pointer-events-auto absolute inset-y-4 left-4 z-10 flex w-12 flex-col items-center gap-1 overflow-y-auto rounded-lg border border-border bg-card/80 p-1 shadow-e2 backdrop-blur-md", "pointer-events-auto absolute inset-y-4 left-4 z-10 flex w-12 flex-col items-center gap-1 rounded-lg border border-border bg-card/80 p-1 shadow-e2 backdrop-blur-md",
// More dashboards than the column is tall still scroll, but no bar is
// ever drawn: a wall panel is swiped, and there is no room for one
// anyway. `w-12` less `p-1` either side is exactly the 40px button, so
// a classic vertical bar claiming its gutter is what pushed the buttons
// out sideways and put a horizontal bar under them — `overflow-y` alone
// computes `overflow-x` to `auto` rather than leaving it visible.
"overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
className, className,
)} )}
> >
@@ -13,6 +13,7 @@ import {
dashboardsQueryOptions, dashboardsQueryOptions,
panelsQueryOptions, panelsQueryOptions,
useSavePanels, useSavePanels,
useUnpairPanel,
} from "@/components/Dashboard/queries" } from "@/components/Dashboard/queries"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
@@ -201,6 +202,12 @@ function PanelRow({
}) { }) {
const { showSuccessToast, showErrorToast } = useCustomToast() const { showSuccessToast, showErrorToast } = useCustomToast()
const [code, setCode] = useState("") const [code, setCode] = useState("")
// Unpairing cannot be undone from here — the screen has to be at hand to
// read out a new code — so the button asks a second time before it fires.
// ponytail: a two-step button rather than a dialog, since this one already
// lives inside a dialog.
const [confirmUnpair, setConfirmUnpair] = useState(false)
const unpair = useUnpairPanel(panel.id)
const assigned = panel.dashboards ?? [] const assigned = panel.dashboards ?? []
const typed = code.trim().toUpperCase() const typed = code.trim().toUpperCase()
@@ -364,6 +371,41 @@ function PanelRow({
: "No device is waiting on that code."} : "No device is waiting on that code."}
</p> </p>
) : null} ) : null}
<div className="flex flex-wrap items-center gap-2">
<Button
variant={confirmUnpair ? "destructive" : "outline"}
disabled={unpair.isPending}
data-testid={`unpair-${panel.id}`}
onBlur={() => setConfirmUnpair(false)}
onClick={() => {
if (!confirmUnpair) {
setConfirmUnpair(true)
return
}
setConfirmUnpair(false)
unpair.mutate(undefined, {
onSuccess: () =>
showSuccessToast(
"Unpaired — the screen asks for a new code.",
),
onError: (error) =>
handleError.call(showErrorToast, error as ApiError),
})
}}
>
{confirmUnpair
? "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>
</div>
</> </>
) : null} ) : null}
</div> </div>
+123 -115
View File
@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { Plus, X } from "lucide-react" import { Ban, ChevronDown, Plus, X } from "lucide-react"
import { useState } from "react" import { useState } from "react"
import type { MessageInfo, WidgetDef } from "@/client" import type { MessageInfo, WidgetDef } from "@/client"
@@ -20,6 +20,12 @@ import {
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Segmented } from "@/components/ui/segmented"
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -37,7 +43,7 @@ import {
columnsOf, columnsOf,
type Dashboard, type Dashboard,
} from "./DashboardView" } from "./DashboardView"
import { ICON_COLORS, ICON_NAMES } from "./icons" import { ICON_COLORS, ICON_NAMES, ICONS } from "./icons"
import { messageCatalogQueryOptions } from "./queries" import { messageCatalogQueryOptions } from "./queries"
import { import {
acceptsDtype, acceptsDtype,
@@ -114,83 +120,104 @@ function MessagePicker({
) )
} }
/** /** Where a chart's lines come from: what the engine kept, or what it asks for. */
* Where a chart's lines come from: what the engine kept, or what it asks for. const CHART_SOURCES = [
*
* The one segmented shape — a single border pill, transparent segments,
* bg-accent on the selected one (root DESIGN-GUIDELINES.md).
*/
function ModePicker({
value,
onChange,
}: {
value: "live" | "query"
onChange: (mode: "live" | "query") => void
}) {
return (
<fieldset
data-testid="chart-source"
className="flex w-fit items-center gap-1 rounded-full border border-border p-1"
>
<legend className="sr-only">Where the chart's data comes from</legend>
{(
[
["live", "Live"], ["live", "Live"],
["query", "Query"], ["query", "Query"],
] as const ] as const
).map(([mode, label]) => (
<button
key={mode}
type="button"
aria-pressed={value === mode}
onClick={() => onChange(mode)}
className={cn(
"rounded-full px-2.5 py-1 text-xs transition-colors",
value === mode
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50",
)}
>
{label}
</button>
))}
</fieldset>
)
}
/** Which chrome an input wears, in the same segmented shape as the mode. */ /**
function StylePicker({ * Pick one of the tile glyphs, or none.
*
* A grid of the glyphs themselves rather than a list of their names: an icon is
* chosen by how it looks. Clearing is a button rather than an option, because
* Radix forbids an empty `SelectItem` value — which is why the selects this
* replaces could set an icon but never take one back.
*
* ponytail: no filter field. `ICONS` is a few dozen and the grid shows all of
* it without scrolling; add one when the map outgrows a popover.
*/
function IconPicker({
value, value,
options, placeholder,
label,
testId,
className,
onChange, onChange,
}: { }: {
value: string value: string
options: readonly (readonly [string, string])[] /** What no icon gets you, on the trigger and on the clearing button. */
onChange: (style: string) => void placeholder: string
/** Names the trigger for screen readers. */
label: string
testId?: string
className?: string
onChange: (icon: string) => void
}) { }) {
const [open, setOpen] = useState(false)
const Current = ICONS[value]
return ( return (
<fieldset <Popover open={open} onOpenChange={setOpen}>
data-testid="widget-style" <PopoverTrigger asChild>
className="flex w-fit items-center gap-1 rounded-full border border-border p-1" <Button
> variant="outline"
<legend className="sr-only">How this control is drawn</legend> // Sits in a row of fields, so it wears their border and surface
{options.map(([style, label]) => ( // rather than a button's.
<button
key={style}
type="button"
aria-pressed={value === style}
onClick={() => onChange(style)}
className={cn( className={cn(
"rounded-full px-2.5 py-1 text-xs transition-colors", "justify-start border-input bg-transparent font-normal",
value === style className,
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50",
)} )}
aria-label={label}
data-testid={testId}
> >
{label} {Current ? <Current /> : null}
</button> <span className={cn("truncate", !value && "text-muted-foreground")}>
))} {value || placeholder}
</fieldset> </span>
<ChevronDown className="ml-auto opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-auto p-2">
<div className="grid grid-cols-6 gap-1">
{ICON_NAMES.map((name) => {
const Glyph = ICONS[name]
return (
<Button
key={name}
variant="ghost"
size="icon-sm"
title={name}
aria-label={name}
aria-pressed={name === value}
className={cn(
name === value && "bg-accent text-accent-foreground",
)}
onClick={() => {
onChange(name)
setOpen(false)
}}
>
<Glyph />
</Button>
)
})}
</div>
<Button
variant="ghost"
size="sm"
className="mt-1 w-full justify-start text-muted-foreground"
data-testid={testId ? `${testId}-clear` : undefined}
onClick={() => {
onChange("")
setOpen(false)
}}
>
<Ban />
{placeholder}
</Button>
</PopoverContent>
</Popover>
) )
} }
@@ -303,8 +330,11 @@ export function WidgetPanel({
</div> </div>
) : widget.type === "chart" ? ( ) : widget.type === "chart" ? (
<div className="grid gap-3"> <div className="grid gap-3">
<ModePicker <Segmented
value={querying ? "query" : "live"} value={querying ? "query" : "live"}
options={CHART_SOURCES}
label="Where the chart's data comes from"
testId="chart-source"
onChange={(source) => set({ source })} onChange={(source) => set({ source })}
/> />
{querying ? ( {querying ? (
@@ -747,30 +777,19 @@ export function WidgetPanel({
) )
} }
/> />
<Select <IconPicker
value={rule.icon ?? ""} value={rule.icon ?? ""}
onValueChange={(icon) => placeholder="No icon"
label="Rule icon"
className="min-w-0 flex-1"
onChange={(icon) =>
setRules( setRules(
rules.map((other, at) => rules.map((other, at) =>
at === index ? { ...other, icon } : other, at === index ? { ...other, icon } : other,
), ),
) )
} }
> />
<SelectTrigger
className="min-w-0 flex-1"
aria-label="Rule icon"
>
<SelectValue placeholder="Icon" />
</SelectTrigger>
<SelectContent>
{ICON_NAMES.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select <Select
value={rule.color ?? "default"} value={rule.color ?? "default"}
onValueChange={(color) => onValueChange={(color) =>
@@ -837,21 +856,13 @@ export function WidgetPanel({
</p> </p>
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<Label className="text-sm font-normal">Otherwise</Label> <Label className="text-sm font-normal">Otherwise</Label>
<Select <IconPicker
value={str(cfg.icon)} value={str(cfg.icon)}
onValueChange={(icon) => set({ icon })} placeholder="Nothing"
> label="Icon when no rule matches"
<SelectTrigger> className="w-full"
<SelectValue placeholder="Nothing" /> onChange={(icon) => set({ icon })}
</SelectTrigger> />
<SelectContent>
{ICON_NAMES.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
</div> </div>
) : null} ) : null}
@@ -860,21 +871,25 @@ export function WidgetPanel({
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<Label className="text-sm font-normal">Style</Label> <Label className="text-sm font-normal">Style</Label>
{widget.type === "switch" ? ( {widget.type === "switch" ? (
<StylePicker <Segmented
value={str(cfg.style) || "track"} value={str(cfg.style) || "track"}
options={[ options={[
["track", "Track"], ["track", "Track"],
["button", "Button"], ["button", "Button"],
]} ]}
label="How this control is drawn"
testId="widget-style"
onChange={(style) => set({ style })} onChange={(style) => set({ style })}
/> />
) : ( ) : (
<StylePicker <Segmented
value={str(cfg.style) || "list"} value={str(cfg.style) || "list"}
options={[ options={[
["list", "List"], ["list", "List"],
["segmented", "Segmented"], ["segmented", "Segmented"],
]} ]}
label="How this control is drawn"
testId="widget-style"
onChange={(style) => set({ style })} onChange={(style) => set({ style })}
/> />
)} )}
@@ -945,21 +960,14 @@ export function DashboardPanel({
<div className="grid gap-5 p-4"> <div className="grid gap-5 p-4">
<div className="grid gap-2"> <div className="grid gap-2">
<span className={PANEL_SECTION}>Rail icon</span> <span className={PANEL_SECTION}>Rail icon</span>
<Select <IconPicker
value={str(dashboard.icon)} value={str(dashboard.icon)}
onValueChange={(icon) => onChange({ icon })} placeholder="Two letters of the title"
> label="Rail icon"
<SelectTrigger data-testid="dashboard-icon"> testId="dashboard-icon"
<SelectValue placeholder="Two letters of the title" /> className="w-full"
</SelectTrigger> onChange={(icon) => onChange({ icon })}
<SelectContent> />
{ICON_NAMES.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Drawn on the rail when a panel carries more than one dashboard. Drawn on the rail when a panel carries more than one dashboard.
</p> </p>
@@ -68,6 +68,21 @@ export function useSavePanels() {
}) })
} }
/**
* Stop honouring one panel's credential, and keep the panel.
*
* The screen goes back to showing a pairing code while its dashboards and their
* arrangement stay — deleting the panel is what throws all three away.
* Superuser-only on the server.
*/
export function useUnpairPanel(id: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => PanelsService.unpairPanel({ panelId: id }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: panelKeys.all }),
})
}
/** Every message any flow declares — what a widget can be pointed at. */ /** Every message any flow declares — what a widget can be pointed at. */
export const messageCatalogQueryOptions = () => ({ export const messageCatalogQueryOptions = () => ({
queryKey: dashboardKeys.messages, queryKey: dashboardKeys.messages,
+74
View File
@@ -0,0 +1,74 @@
// The thumb's transition lives beside the dashboard's own widgets, and CSS is
// chunked per entry — so the rule is pulled in wherever this control is used,
// or two copies of one shape would move differently.
import "@/components/Dashboard/dashboard.css"
import { cn } from "@/lib/utils"
/**
* One of N, as the one segmented shape: a single border pill, no dividers,
* transparent segments, and `bg-accent` held by a thumb that slides rather than
* a fill that jumps from cell to cell (root DESIGN-GUIDELINES.md).
*
* ponytail: sized for editor chrome — `w-fit`, `text-xs`, a mouse-sized target.
* The widget-side copy in `Dashboard/widgets.tsx` is a full-width pill with a
* 44px touch target, so pointing that one here needs a size prop first.
*/
export function Segmented({
value,
options,
label,
testId,
onChange,
}: {
value: string
/** `[value, label]` pairs, in the order they are drawn. */
options: readonly (readonly [string, string])[]
/** Names the group for screen readers. */
label: string
testId?: string
onChange: (value: string) => void
}) {
const chosen = options.findIndex(([option]) => option === value)
return (
// A `fieldset` carries `min-inline-size: min-content` from the UA sheet,
// which no width utility overrides. Equal tracks and no gap put the thumb
// at its share of the padded box without measuring — a grid rather than a
// flex row because `flex-1` under `w-fit` sizes the segments to a share of
// the widest label instead of to the label itself.
<fieldset
data-testid={testId}
className="relative grid w-fit min-w-0 items-center rounded-full border border-border p-1"
style={{
gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))`,
}}
>
<legend className="sr-only">{label}</legend>
{chosen >= 0 ? (
<span
aria-hidden
className="widget-segment-thumb pointer-events-none absolute inset-y-1 rounded-full bg-accent"
style={{
left: `calc(0.25rem + ${chosen} * (100% - 0.5rem) / ${options.length})`,
width: `calc((100% - 0.5rem) / ${options.length})`,
}}
/>
) : null}
{options.map(([option, optionLabel], index) => (
<button
key={option}
type="button"
aria-pressed={index === chosen}
onClick={() => onChange(option)}
className={cn(
"relative z-10 min-w-0 truncate rounded-full px-2.5 py-1 text-xs transition-colors",
index === chosen
? "text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50",
)}
>
{optionLabel}
</button>
))}
</fieldset>
)
}