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