From d7ca93347883157cc93d9e9331ecd8aa65d8a177 Mon Sep 17 00:00:00 2001 From: stroblme Date: Tue, 6 Oct 2026 00:03:39 +0200 Subject: [PATCH] Float the dashboard rail along the bottom on a phone Below md a dashboard stacks to one column and dropped its rail. The rail is now a horizontal row in the dashboard's own look, floated over the bottom of the screen on /panel/{id} and above the dock in the app's view, with 44px items, sideways scrolling when it overflows, and padding so the last tile clears it; a panel's read-only notice moves up out of its way. The three looks share the geometry from ui/core. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013GAeg4Jo6xjZzjwy9QDWMQ --- docs/interface/dashboards.md | 4 +- .../components/Dashboard/DashboardEditor.tsx | 57 ++++++++++--------- .../src/components/Dashboard/PanelRail.tsx | 5 +- .../src/components/Dashboard/PanelSurface.tsx | 26 ++++++++- .../components/Dashboard/ui/core/contract.ts | 18 ++++++ .../src/components/Dashboard/ui/core/core.css | 6 ++ .../Dashboard/ui/fluksio/Surfaces.tsx | 18 +++--- .../Dashboard/ui/glass/Surfaces.tsx | 18 +++--- .../Dashboard/ui/material/Surfaces.tsx | 18 +++--- frontend/src/routes/panel.$id.tsx | 3 +- 10 files changed, 108 insertions(+), 65 deletions(-) diff --git a/docs/interface/dashboards.md b/docs/interface/dashboards.md index 3c3a2fd..4383f4f 100644 --- a/docs/interface/dashboards.md +++ b/docs/interface/dashboards.md @@ -240,7 +240,9 @@ A wall tablet has no keyboard, so it pairs. 1. **Dashboards → Panels**, add a panel named after where it hangs, and tick the dashboards it shows. More than one and the screen draws a rail to switch - between them, with each dashboard's own icon on it. + between them, with each dashboard's own icon on it. On a phone the rail is a + row floating along the bottom; the app shows it there too, above its own + buttons, for a dashboard that hangs on such a panel. 2. Tick **Touch friendly** if the screen is touched rather than pointed at. Controls and the rail grow to a finger's size — the rail gets taller without taking a wider column, so the arrangement does not move. It sits here rather diff --git a/frontend/src/components/Dashboard/DashboardEditor.tsx b/frontend/src/components/Dashboard/DashboardEditor.tsx index 72904c7..b5bb194 100644 --- a/frontend/src/components/Dashboard/DashboardEditor.tsx +++ b/frontend/src/components/Dashboard/DashboardEditor.tsx @@ -361,6 +361,20 @@ export function DashboardEditor({ (panel.dashboards ?? []).includes(draft.name), )?.dashboards ?? null const hasRail = Boolean(railDashboards) && !stacked + const rail = railDashboards ? ( + ({ + to: "/dashboards/$name", + params: { name }, + search: edit ? { edit: true } : {}, + })} + /> + ) : null // What the arrangement is laid out in: the canvas less its margin, and less // the rail's column when the panel carries one. const area = areaOf(draft, hasRail) @@ -453,38 +467,29 @@ export function DashboardEditor({ data-touch={root["data-touch"]} data-palette={root["data-palette"]} className={cn( - "h-full overflow-y-auto bg-background text-foreground", + "relative h-full bg-background text-foreground", root.className, )} style={root.style} > - +
+ +
+ {rail ? ( + // No column to spare on a phone, so the rail is a row floating over + // the bottom of the stack, just above the dock. +
+ {rail} +
+ ) : null} ) : ( - ({ - to: "/dashboards/$name", - params: { name }, - search: edit ? { edit: true } : {}, - })} - /> - ) : null - } - > + {(scale) => !edit ? ( diff --git a/frontend/src/components/Dashboard/PanelRail.tsx b/frontend/src/components/Dashboard/PanelRail.tsx index 6de8d88..d179edf 100644 --- a/frontend/src/components/Dashboard/PanelRail.tsx +++ b/frontend/src/components/Dashboard/PanelRail.tsx @@ -15,6 +15,7 @@ import { useUi } from "./ui" * * Drawn in the dashboard's own look, because it hangs on the same screen: a * rail wearing the app's chrome beside a glass panel is two designs at once. + * On a phone it is a row the caller floats along the bottom. * * Reading the dashboards it links to is also what fills the labels, and it * warms the cache for the neighbours so a switch draws immediately. @@ -23,10 +24,12 @@ export function PanelRail({ dashboards, current, linkFor, + horizontal, }: { dashboards: string[] current: string linkFor: (name: string) => LinkProps + horizontal?: boolean }) { const { Rail } = useUi() const entries = useQueries({ @@ -41,5 +44,5 @@ export function PanelRail({ })), }) - return + return } diff --git a/frontend/src/components/Dashboard/PanelSurface.tsx b/frontend/src/components/Dashboard/PanelSurface.tsx index c0da0f6..881da05 100644 --- a/frontend/src/components/Dashboard/PanelSurface.tsx +++ b/frontend/src/components/Dashboard/PanelSurface.tsx @@ -8,6 +8,7 @@ import { import { useDashboardLocked } from "@/components/Dashboard/settings" import { useUi } from "@/components/Dashboard/ui" import { LookProvider } from "@/components/Dashboard/ui/core/look" +import { cn } from "@/lib/utils" /** * One dashboard filling whatever screen it landed on. @@ -30,7 +31,8 @@ export function PanelSurface({ /** A landscape arrangement scaled onto a phone comes out at about a fifth of * its size, which reads as nothing at all. Stack it instead. */ stacked?: boolean - /** The way between this panel's dashboards, drawn on the canvas itself. */ + /** The way between this panel's dashboards, drawn on the canvas itself, or + * along the bottom of the screen when stacked. */ rail?: React.ReactNode /** Whether the screen this hangs on is touched. Stated by the panel route. */ touch?: boolean @@ -49,7 +51,18 @@ export function PanelSurface({ // the look on: this branch mounts what `CanvasSurface` mounts for the // other one, touch included. - + {/* Padded here rather than by the route: the stack overflows this + box, so only padding inside the overflow clears the rail. */} +
+ +
+ {rail ? ( + // No column to spare on a phone, so the rail is a row floating + // over the bottom edge. +
+ {rail} +
+ ) : null}
) : ( // The panel's own surface, scaled to fit. No dots: nothing is being @@ -60,7 +73,14 @@ export function PanelSurface({ )} {/* Outside the canvas, so it needs the look stated for it. */} - + {stacked && rail ? ( + // Lifted clear of the rail floating along the bottom. +
+ +
+ ) : ( + + )}
) diff --git a/frontend/src/components/Dashboard/ui/core/contract.ts b/frontend/src/components/Dashboard/ui/core/contract.ts index f686d1a..746bdd0 100644 --- a/frontend/src/components/Dashboard/ui/core/contract.ts +++ b/frontend/src/components/Dashboard/ui/core/contract.ts @@ -147,8 +147,26 @@ export type RailProps = { active: boolean link: LinkProps }[] + /** A row rather than a column, for a phone; whoever draws it places it. */ + horizontal?: boolean } +/** + * Where a rail runs, shared by the looks, which only paint it. + * + * A column centred down the canvas's left edge, as tall as what it carries: a + * rail of two stretched to the height of the panel is mostly empty pill. Below + * `md` there is no column to spare, so it is a row instead, floated along the + * bottom by its caller. More dashboards than fit still scroll, but no bar is + * ever drawn — a wall panel is swiped, and there is no room. + */ +export const railGeometry = (horizontal = false) => + `pointer-events-auto z-10 flex items-center gap-1 p-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden ${ + horizontal + ? "max-w-full flex-row overflow-x-auto" + : "absolute left-3 top-1/2 max-h-[calc(100%-1.5rem)] w-12 -translate-y-1/2 flex-col overflow-y-auto" + }` + export type NoticeProps = { children: React.ReactNode } /** The ambient ground under the widgets, or the image that replaces it. */ diff --git a/frontend/src/components/Dashboard/ui/core/core.css b/frontend/src/components/Dashboard/ui/core/core.css index b591396..3d8aadb 100644 --- a/frontend/src/components/Dashboard/ui/core/core.css +++ b/frontend/src/components/Dashboard/ui/core/core.css @@ -83,6 +83,12 @@ height: 1.5rem; } +/* The row a phone gets has no column to fit, and a phone is touched. */ +.dui-rail[data-orientation="horizontal"] .dui-rail-item { + width: 2.75rem; + height: 2.75rem; +} + /* * Pulled up half a step, so the title reads from the centre of the corner * radius rather than from below it — and the body, which is what anyone is diff --git a/frontend/src/components/Dashboard/ui/fluksio/Surfaces.tsx b/frontend/src/components/Dashboard/ui/fluksio/Surfaces.tsx index 9062903..5b02c02 100644 --- a/frontend/src/components/Dashboard/ui/fluksio/Surfaces.tsx +++ b/frontend/src/components/Dashboard/ui/fluksio/Surfaces.tsx @@ -23,7 +23,7 @@ import type { NoticeProps, RailProps, } from "../core/contract" -import { TESTID } from "../core/contract" +import { railGeometry, TESTID } from "../core/contract" import { LOOK } from "../core/motion" /** The page colour is the ground; only an image is drawn over it. */ @@ -110,19 +110,13 @@ export function Frame({ ) } -export function Rail({ entries }: RailProps) { +export function Rail({ entries, horizontal }: RailProps) { return (