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 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 (
-
+ {Current ? : null}
+
+ {value || placeholder}
+
+
+
+
+
+
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.
+
+ )
+}