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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GAeg4Jo6xjZzjwy9QDWMQ
This commit is contained in:
2026-10-06 00:22:28 +02:00
co-authored by Claude Opus 5.5
parent 2c1ad395de
commit d7ca933478
10 changed files with 108 additions and 65 deletions
+3 -1
View File
@@ -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
@@ -361,6 +361,20 @@ export function DashboardEditor({
(panel.dashboards ?? []).includes(draft.name),
)?.dashboards ?? null
const hasRail = Boolean(railDashboards) && !stacked
const rail = railDashboards ? (
<PanelRail
dashboards={railDashboards}
current={draft.name}
horizontal={stacked}
// Editing one dashboard of a panel and editing its neighbour are the
// same job, so the rail keeps whichever mode this one is in.
linkFor={(name) => ({
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}
>
<DashboardView
dashboard={draft}
stacked
editing={edit}
renderWidget={edit ? pickable : undefined}
/>
<div className={cn("h-full overflow-y-auto", rail && "pb-20")}>
<DashboardView
dashboard={draft}
stacked
editing={edit}
renderWidget={edit ? pickable : undefined}
/>
</div>
{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.
<div className="pointer-events-none absolute inset-x-0 bottom-3 z-10 flex justify-center">
{rail}
</div>
) : null}
</div>
) : (
<CanvasSurface
dashboard={draft}
dots={edit}
rail={
railDashboards ? (
<PanelRail
dashboards={railDashboards}
current={draft.name}
// Editing one dashboard of a panel and editing its neighbour are
// the same job, so the rail keeps whichever mode this one is in.
linkFor={(name) => ({
to: "/dashboards/$name",
params: { name },
search: edit ? { edit: true } : {},
})}
/>
) : null
}
>
<CanvasSurface dashboard={draft} dots={edit} rail={rail}>
{(scale) =>
!edit ? (
<DashboardView dashboard={draft} rail={hasRail} />
@@ -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 <Rail entries={entries} />
return <Rail entries={entries} horizontal={horizontal} />
}
@@ -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.
<LookProvider dashboard={dashboard} touch={touch}>
<DashboardView dashboard={dashboard} stacked />
{/* Padded here rather than by the route: the stack overflows this
box, so only padding inside the overflow clears the rail. */}
<div className={cn(rail && "pb-24")}>
<DashboardView dashboard={dashboard} stacked />
</div>
{rail ? (
// No column to spare on a phone, so the rail is a row floating
// over the bottom edge.
<div className="pointer-events-none fixed inset-x-4 bottom-[calc(1rem+env(safe-area-inset-bottom))] z-10 flex justify-center">
{rail}
</div>
) : null}
</LookProvider>
) : (
// 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. */}
<LookProvider dashboard={dashboard} touch={touch}>
<LockNotice dashboard={dashboard} />
{stacked && rail ? (
// Lifted clear of the rail floating along the bottom.
<div className="pointer-events-none absolute inset-x-0 top-0 bottom-20">
<LockNotice dashboard={dashboard} />
</div>
) : (
<LockNotice dashboard={dashboard} />
)}
</LookProvider>
</div>
)
@@ -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. */
@@ -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
@@ -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 (
<nav
aria-label="Dashboards on this panel"
data-testid={TESTID.rail}
className={cn(
"dui-rail fx-rail pointer-events-auto absolute left-3 top-1/2 z-10 flex max-h-[calc(100%-1.5rem)] w-12 -translate-y-1/2 flex-col items-center gap-1 p-1",
// As tall as what it carries, centred in the column it reserves: a
// rail of two stretched to the height of the panel is mostly empty
// pill. More dashboards than the panel is tall still scroll, but no
// bar is ever drawn — a wall panel is swiped, and there is no room.
"overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
)}
data-orientation={horizontal ? "horizontal" : undefined}
className={cn("dui-rail fx-rail", railGeometry(horizontal))}
>
{entries.map((entry) => {
const Glyph = ICONS[entry.icon ?? ""]
@@ -149,7 +143,9 @@ export function Rail({ entries }: RailProps) {
</span>
</Link>
</TooltipTrigger>
<TooltipContent side="right">{entry.label}</TooltipContent>
<TooltipContent side={horizontal ? "top" : "right"}>
{entry.label}
</TooltipContent>
</Tooltip>
)
})}
@@ -22,7 +22,7 @@ import type {
NoticeProps,
RailProps,
} from "../core/contract"
import { TESTID } from "../core/contract"
import { railGeometry, TESTID } from "../core/contract"
import { LOOK } from "../core/motion"
/**
@@ -143,19 +143,13 @@ export function Frame({
)
}
export function Rail({ entries }: RailProps) {
export function Rail({ entries, horizontal }: RailProps) {
return (
<nav
aria-label="Dashboards on this panel"
data-testid={TESTID.rail}
className={cn(
"dui-rail gl-surface gl-rail pointer-events-auto absolute left-3 top-1/2 z-10 flex max-h-[calc(100%-1.5rem)] w-12 -translate-y-1/2 flex-col items-center gap-1 p-1",
// As tall as what it carries, centred in the column it reserves: a
// rail of two stretched to the height of the panel is mostly empty
// pill. More dashboards than the panel is tall still scroll, but no
// bar is ever drawn — a wall panel is swiped, and there is no room.
"overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
)}
data-orientation={horizontal ? "horizontal" : undefined}
className={cn("dui-rail gl-surface gl-rail", railGeometry(horizontal))}
>
{entries.map((entry) => {
const Glyph = ICONS[entry.icon ?? ""]
@@ -186,7 +180,9 @@ export function Rail({ entries }: RailProps) {
</span>
</Link>
</TooltipTrigger>
<TooltipContent side="right">{entry.label}</TooltipContent>
<TooltipContent side={horizontal ? "top" : "right"}>
{entry.label}
</TooltipContent>
</Tooltip>
)
})}
@@ -22,7 +22,7 @@ import type {
NoticeProps,
RailProps,
} from "../core/contract"
import { TESTID } from "../core/contract"
import { railGeometry, TESTID } from "../core/contract"
import { LOOK } from "../core/motion"
/** Material's ground is the surface colour itself; only an image is drawn. */
@@ -109,19 +109,13 @@ export function Frame({
)
}
export function Rail({ entries }: RailProps) {
export function Rail({ entries, horizontal }: RailProps) {
return (
<nav
aria-label="Dashboards on this panel"
data-testid={TESTID.rail}
className={cn(
"dui-rail m3-rail pointer-events-auto absolute left-3 top-1/2 z-10 flex max-h-[calc(100%-1.5rem)] w-12 -translate-y-1/2 flex-col items-center gap-1 p-1",
// As tall as what it carries, centred in the column it reserves: a
// rail of two stretched to the height of the panel is mostly empty
// pill. More dashboards than the panel is tall still scroll, but no
// bar is ever drawn — a wall panel is swiped, and there is no room.
"overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
)}
data-orientation={horizontal ? "horizontal" : undefined}
className={cn("dui-rail m3-rail", railGeometry(horizontal))}
>
{entries.map((entry) => {
const Glyph = ICONS[entry.icon ?? ""]
@@ -155,7 +149,9 @@ export function Rail({ entries }: RailProps) {
</span>
</Link>
</TooltipTrigger>
<TooltipContent side="right">{entry.label}</TooltipContent>
<TooltipContent side={horizontal ? "top" : "right"}>
{entry.label}
</TooltipContent>
</Tooltip>
)
})}
+2 -1
View File
@@ -21,7 +21,7 @@ import { cn } from "@/lib/utils"
* What a paired device shows: the dashboards this panel was assigned.
*
* The same full-bleed surface as `/view/{name}`, with a rail down the left
* when there is more than one to switch between — so a hallway tablet and a
* (along the bottom on a phone) when there is more than one to switch between — so a hallway tablet and a
* workshop tablet can carry different sets without either dashboard knowing
* anything about the other.
*
@@ -112,6 +112,7 @@ function PanelRoute() {
<PanelRail
dashboards={dashboards}
current={current}
horizontal={stacked}
linkFor={(name) => ({
to: "/panel/$id",
params: { id },