From 6be6f18dc295b6fc6fb1176aecce9ca37bf3e094 Mon Sep 17 00:00:00 2001 From: stroblme Date: Sat, 22 Aug 2026 12:16:17 +0200 Subject: [PATCH] Dashboard chrome: an icon picker, one segmented shape, a rail without bars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu --- .../src/components/Dashboard/PanelRail.tsx | 9 +- .../src/components/Dashboard/PanelsDialog.tsx | 42 +++ frontend/src/components/Dashboard/panels.tsx | 242 +++++++++--------- frontend/src/components/Dashboard/queries.ts | 15 ++ frontend/src/components/ui/segmented.tsx | 74 ++++++ 5 files changed, 264 insertions(+), 118 deletions(-) create mode 100644 frontend/src/components/ui/segmented.tsx diff --git a/frontend/src/components/Dashboard/PanelRail.tsx b/frontend/src/components/Dashboard/PanelRail.tsx index aa38118..d75cf85 100644 --- a/frontend/src/components/Dashboard/PanelRail.tsx +++ b/frontend/src/components/Dashboard/PanelRail.tsx @@ -60,7 +60,14 @@ export function PanelRail({ aria-label="Dashboards on this panel" data-testid="panel-rail" 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, )} > diff --git a/frontend/src/components/Dashboard/PanelsDialog.tsx b/frontend/src/components/Dashboard/PanelsDialog.tsx index f4850fd..e9de859 100644 --- a/frontend/src/components/Dashboard/PanelsDialog.tsx +++ b/frontend/src/components/Dashboard/PanelsDialog.tsx @@ -13,6 +13,7 @@ import { dashboardsQueryOptions, panelsQueryOptions, useSavePanels, + useUnpairPanel, } from "@/components/Dashboard/queries" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" @@ -201,6 +202,12 @@ function PanelRow({ }) { const { showSuccessToast, showErrorToast } = useCustomToast() 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 typed = code.trim().toUpperCase() @@ -364,6 +371,41 @@ function PanelRow({ : "No device is waiting on that code."}

) : null} + +
+ +

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

+
) : null} diff --git a/frontend/src/components/Dashboard/panels.tsx b/frontend/src/components/Dashboard/panels.tsx index c24f322..bd0bdaa 100644 --- a/frontend/src/components/Dashboard/panels.tsx +++ b/frontend/src/components/Dashboard/panels.tsx @@ -1,5 +1,5 @@ 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 type { MessageInfo, WidgetDef } from "@/client" @@ -20,6 +20,12 @@ import { } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Segmented } from "@/components/ui/segmented" import { Select, SelectContent, @@ -37,7 +43,7 @@ import { columnsOf, type Dashboard, } from "./DashboardView" -import { ICON_COLORS, ICON_NAMES } from "./icons" +import { ICON_COLORS, ICON_NAMES, ICONS } from "./icons" import { messageCatalogQueryOptions } from "./queries" import { acceptsDtype, @@ -114,83 +120,104 @@ function MessagePicker({ ) } -/** - * Where a chart's lines come from: what the engine kept, or what it asks for. - * - * 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 ( -
- Where the chart's data comes from - {( - [ - ["live", "Live"], - ["query", "Query"], - ] as const - ).map(([mode, label]) => ( - - ))} -
- ) -} +/** Where a chart's lines come from: what the engine kept, or what it asks for. */ +const CHART_SOURCES = [ + ["live", "Live"], + ["query", "Query"], +] as const -/** 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, - options, + placeholder, + label, + testId, + className, onChange, }: { value: string - options: readonly (readonly [string, string])[] - onChange: (style: string) => void + /** What no icon gets you, on the trigger and on the clearing button. */ + 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 ( -
- How this control is drawn - {options.map(([style, label]) => ( - - ))} -
+ {Current ? : null} + + {value || placeholder} + + + + + +
+ {ICON_NAMES.map((name) => { + const Glyph = ICONS[name] + return ( + + ) + })} +
+ +
+ ) } @@ -303,8 +330,11 @@ export function WidgetPanel({ ) : widget.type === "chart" ? (
- set({ source })} /> {querying ? ( @@ -747,30 +777,19 @@ export function WidgetPanel({ ) } /> - + /> set({ icon })} - > - - - - - {ICON_NAMES.map((name) => ( - - {name} - - ))} - - + placeholder="Nothing" + label="Icon when no rule matches" + className="w-full" + onChange={(icon) => set({ icon })} + />
) : null} @@ -860,21 +871,25 @@ export function WidgetPanel({
{widget.type === "switch" ? ( - set({ style })} /> ) : ( - set({ style })} /> )} @@ -945,21 +960,14 @@ export function DashboardPanel({
Rail icon - + placeholder="Two letters of the title" + label="Rail icon" + testId="dashboard-icon" + className="w-full" + onChange={(icon) => onChange({ icon })} + />

Drawn on the rail when a panel carries more than one dashboard.

diff --git a/frontend/src/components/Dashboard/queries.ts b/frontend/src/components/Dashboard/queries.ts index 2d412ee..fffca1d 100644 --- a/frontend/src/components/Dashboard/queries.ts +++ b/frontend/src/components/Dashboard/queries.ts @@ -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. */ export const messageCatalogQueryOptions = () => ({ queryKey: dashboardKeys.messages, diff --git a/frontend/src/components/ui/segmented.tsx b/frontend/src/components/ui/segmented.tsx new file mode 100644 index 0000000..cba6323 --- /dev/null +++ b/frontend/src/components/ui/segmented.tsx @@ -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. +
+ {label} + {chosen >= 0 ? ( + + ) : null} + {options.map(([option, optionLabel], index) => ( + + ))} +
+ ) +}