Touch is a panel setting, and the rail grows with it
Docs / docs (push) Successful in 22s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m9s
Playwright Tests / test-playwright (2, 2) (push) Failing after 11s
pre-commit / pre-commit (push) Failing after 1m59s
Test Backend / test-backend (push) Failing after 2m28s
Compose Smoke Test / test-compose (push) Failing after 11s
Playwright Tests / merge-reports (push) Failing after 2m19s

It described the wrong object. A dashboard is a document that may hang on a
hallway tablet and in a desk browser at the same time, and only one of those
has fingers on it — so the flag moves off `DashboardDef.settings` and onto
`PanelDef` as a plain bool, ticked in the Panels dialog. `useCanvasRoot` takes
it as an argument rather than reading the document, and `/panel/{id}` is the
only surface with a panel to ask.

Dropping the message binding with it is deliberate: nothing drove it, and a
flow deciding whether a screen has fingers on it was never the point. A stored
`settings.touch` is inert rather than migrated, which `_check_settings`
skipping unknown names already guaranteed.

The rail was the other half. It had no touch behaviour at all and its 40px
buttons met neither branch of the 44/32 rule. `[data-touch] .dui-rail{-item}`
in `ui/core/core.css` spends the padding and the gap on the buttons instead,
so they reach the 44px target and the rail comes out taller at exactly the
same width — `RAIL_INSET` never moves, and the arrangement under it does not
either.

Also closes the panels-dialog icon gap: `DashboardSummary` carries the `icon`
now, so the dialog draws each assigned dashboard's rail glyph beside its
checkbox. `initials()` went from three identical copies in the looks to one in
`Dashboard/icons.ts`, so the dialog and the rail fall back the same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va7ExQDtuwKN7kNpHhWWNQ
This commit is contained in:
2026-08-31 19:04:20 +02:00
co-authored by Claude Opus 5
parent 8a94bf10d7
commit 518231aa39
22 changed files with 308 additions and 124 deletions
+10
View File
@@ -556,6 +556,11 @@ export const DashboardSummarySchema = {
title: 'Title',
default: ''
},
icon: {
type: 'string',
title: 'Icon',
default: ''
},
widget_count: {
type: 'integer',
title: 'Widget Count',
@@ -2355,6 +2360,11 @@ export const PanelDefSchema = {
type: 'array',
title: 'Dashboards'
},
touch: {
type: 'boolean',
title: 'Touch',
default: false
},
nonce: {
type: 'integer',
title: 'Nonce',
File diff suppressed because one or more lines are too long
+8
View File
@@ -154,6 +154,7 @@ export type DashboardsPublic = {
export type DashboardSummary = {
name: string;
title?: string;
icon?: string;
widget_count?: number;
has_draft?: boolean;
version?: number;
@@ -848,6 +849,7 @@ export type PanelDef = {
id: string;
title?: string;
dashboards?: Array<(string)>;
touch?: boolean;
nonce?: number;
};
@@ -1872,6 +1874,12 @@ export type RunsCancelRunData = {
export type RunsCancelRunResponse = (fluksio__api__routes__runs__RunRow);
export type RunsRetryRunData = {
runId: string;
};
export type RunsRetryRunResponse = (fluksio__api__routes__runs__RunRow);
export type RunsReadMetricsData = {
name?: string;
runId: string;
@@ -156,6 +156,7 @@ export function CanvasSurface({
dashboard,
dots,
rail,
touch,
children,
}: {
dashboard: Dashboard
@@ -163,6 +164,8 @@ export function CanvasSurface({
dots?: boolean
/** The dashboard-switching rail, drawn on the panel rather than beside it. */
rail?: React.ReactNode
/** Whether the panel this canvas hangs on is touched rather than pointed at. */
touch?: boolean
children: (scale: number) => React.ReactNode
}) {
const ref = useRef<HTMLDivElement>(null)
@@ -183,7 +186,7 @@ export function CanvasSurface({
const { width, height } = canvasOf(dashboard)
const scale = Math.min(box.width / width, box.height / height)
const root = useCanvasRoot(dashboard)
const root = useCanvasRoot(dashboard, touch)
const area = areaOf(dashboard, Boolean(rail))
return (
@@ -191,7 +194,7 @@ export function CanvasSurface({
{/* Measured first: a guessed scale would place the whole panel once and
then move it. */}
{scale > 0 ? (
<LookProvider dashboard={dashboard}>
<LookProvider dashboard={dashboard} touch={touch}>
<div
data-look={root["data-look"]}
data-touch={root["data-touch"]}
@@ -22,6 +22,7 @@ export function PanelSurface({
dashboard,
stacked,
rail,
touch,
}: {
dashboard: Dashboard
/** A landscape arrangement scaled onto a phone comes out at about a fifth of
@@ -29,6 +30,8 @@ export function PanelSurface({
stacked?: boolean
/** The way between this panel's dashboards, drawn on the canvas itself. */
rail?: React.ReactNode
/** Whether the screen this hangs on is touched. Stated by the panel route. */
touch?: boolean
}) {
return (
<div className="relative size-full">
@@ -37,12 +40,12 @@ export function PanelSurface({
) : (
// The panel's own surface, scaled to fit. No dots: nothing is being
// arranged here.
<CanvasSurface dashboard={dashboard} rail={rail}>
<CanvasSurface dashboard={dashboard} rail={rail} touch={touch}>
{() => <DashboardView dashboard={dashboard} rail={Boolean(rail)} />}
</CanvasSurface>
)}
{/* Outside the canvas, so it needs the look stated for it. */}
<LookProvider dashboard={dashboard}>
<LookProvider dashboard={dashboard} touch={touch}>
<LockNotice dashboard={dashboard} />
</LookProvider>
</div>
@@ -9,6 +9,7 @@ import {
type PanelsConfig,
PanelsService,
} from "@/client"
import { initials, resolveIcon } from "@/components/Dashboard/icons"
import {
dashboardsQueryOptions,
panelsQueryOptions,
@@ -26,6 +27,7 @@ import {
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import useAuth from "@/hooks/useAuth"
import useCustomToast from "@/hooks/useCustomToast"
import { cn } from "@/lib/utils"
@@ -120,6 +122,7 @@ export function PanelsDialog() {
dashboards={known.map((dashboard) => ({
name: dashboard.name,
title: dashboard.title || dashboard.name,
icon: dashboard.icon ?? "",
}))}
onChange={(next) => replace(panel.id, next)}
onRemove={() =>
@@ -196,7 +199,7 @@ function PanelRow({
* links — worth seeing — with the writes turned off rather than a 403.
*/
canEdit: boolean
dashboards: { name: string; title: string }[]
dashboards: { name: string; title: string; icon: string }[]
onChange: (next: PanelDef) => void
onRemove: () => void
}) {
@@ -294,6 +297,25 @@ function PanelRow({
</Button>
</div>
<label
htmlFor={`touch-${panel.id}`}
className="flex items-center gap-2 text-sm"
>
<span className="flex-1">Touch friendly</span>
<InfoTip label="Touch friendly">
Bigger controls and a bigger rail, and nothing that only happens on
hover. Set here rather than on a dashboard because it describes the
screen: the same dashboard may also hang somewhere with a mouse.
</InfoTip>
<Switch
id={`touch-${panel.id}`}
checked={panel.touch === true}
disabled={!canEdit}
data-testid={`panel-touch-${panel.id}`}
onCheckedChange={(touch) => onChange({ ...panel, touch })}
/>
</label>
{dashboards.length === 0 ? (
<p className="text-sm text-muted-foreground">
No dashboards to assign yet.
@@ -303,6 +325,10 @@ function PanelRow({
{dashboards.map((dashboard) => {
const position = assigned.indexOf(dashboard.name)
const id = `assign-${panel.id}-${dashboard.name}`
// What this dashboard draws on the rail. Shown rather than set: the
// icon belongs to the document, and this dialog has no draft to put
// a change into.
const Glyph = resolveIcon(dashboard.icon)
return (
<label
key={dashboard.name}
@@ -316,6 +342,16 @@ function PanelRow({
disabled={!canEdit}
onCheckedChange={() => toggle(dashboard.name)}
/>
<span
aria-hidden
className="flex size-5 shrink-0 items-center justify-center text-xs font-medium text-muted-foreground"
>
{Glyph ? (
<Glyph className="size-4" />
) : (
initials(dashboard.title)
)}
</span>
<span className="flex-1 truncate">{dashboard.title}</span>
{position >= 0 ? (
<span className="text-xs text-muted-foreground">
@@ -100,6 +100,20 @@ export function resolveIcon(name: string): LucideIcon | null {
return CircleHelp
}
/**
* Two letters off a title, so a rail of four reads as four different things.
*
* The fallback wherever a glyph is drawn from a name that may be empty: the
* rail in all three looks, and the panels dialog showing what the rail will
* draw.
*/
export function initials(label: string): string {
const words = label.split(/[\s_-]+/).filter(Boolean)
if (words.length === 0) return "?"
if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
return (words[0][0] + words[1][0]).toUpperCase()
}
/**
* What an icon may be tinted, by name.
*
@@ -1521,7 +1521,6 @@ function PalettePicker({
/** How a setting's own value reads in the "nothing is driving it" line. */
function valueLabel(name: SettingName, value: unknown): string {
if (name === "locked") return value === true ? "read-only" : "editable"
if (name === "touch") return value === true ? "touch friendly" : "pointer"
if (name === "look") return lookOf(value)
if (name === "background") return value ? "that image" : "no image"
if (name === "palette") {
@@ -1560,7 +1559,6 @@ export function DashboardPanel({
const locked = settingOf(dashboard, "locked")
const look = settingOf(dashboard, "look")
const background = settingOf(dashboard, "background")
const touch = settingOf(dashboard, "touch")
const paletteSetting = settingOf(dashboard, "palette")
const palette = parsePalette(paletteSetting.value)
@@ -1765,28 +1763,6 @@ export function DashboardPanel({
/>
</PanelSection>
<PanelSection
title="Touch"
help="Bigger controls, and nothing that only happens on hover, for a panel that is touched rather than pointed at. A phone gets this anyway; a wall panel cannot say so for itself."
>
<div className="flex items-center justify-between gap-2 text-sm">
Touch friendly
<Switch
checked={touch.value === true}
aria-label="Touch friendly"
data-testid="dashboard-touch"
onCheckedChange={(value) =>
setSetting("touch", { ...touch, value })
}
/>
</div>
<SettingBinding
name="touch"
setting={touch}
onChange={(setting) => setSetting("touch", setting)}
/>
</PanelSection>
<PanelSection
title="Lock"
help="Locked, the controls are still shown but stop publishing, and the surface says so. This is a read-only surface, not a permission: what a paired screen may reach is decided by its own credential."
@@ -40,8 +40,6 @@ export const SETTING_DTYPES = {
palette: "list",
/** The URL of an image drawn under the widgets. */
background: "str",
/** Bigger controls, for a panel that is touched rather than pointed at. */
touch: "bool",
} as const satisfies Record<string, string>
/** The settings this build actually wires up. */
@@ -119,11 +117,6 @@ export function useDashboardBackground(
return typeof value === "string" ? value.trim() : ""
}
/** Whether this dashboard is drawn for a finger rather than a pointer. */
export const useDashboardTouch = (
dashboard: DashboardDef_Output | undefined,
): boolean => useSetting(dashboard, "touch") === true
/**
* The class that themes a dashboard's own surface, or `""` to follow the app.
*
@@ -56,6 +56,33 @@
line-height: 1.25;
}
/*
* The rail grows into the column it already has rather than taking a wider one.
*
* `RAIL_INSET` reserves the same 72px either way — the arrangement must not
* move because a screen was told it has fingers on it — so the padding and the
* gap pay for the buttons, and the rail comes out taller rather than wider.
* 48px of width less two 2px margins is the 44px target the guidelines ask for.
*
* Geometry here, paint in each look: the sizes are Tailwind literals on the
* three `Rail`s, which this beats because the file lands unlayered.
*/
[data-touch] .dui-rail {
padding: 0.125rem;
gap: 0.125rem;
}
[data-touch] .dui-rail-item {
width: 2.75rem;
height: 2.75rem;
font-size: 0.875rem;
}
[data-touch] .dui-rail-item svg {
width: 1.5rem;
height: 1.5rem;
}
/*
* 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
@@ -1,9 +1,10 @@
/**
* Which look is being drawn, and what the canvas it is drawn on carries.
*
* A dashboard states its look, its colours and whether it is touched on one
* element — the canvas root — and everything below reads them from there:
* the tokens by inheritance, the look and the touch flag through this context.
* A dashboard states its look and its colours, and the panel it hangs on says
* whether it is touched, on one element — the canvas root — and everything
* below reads them from there: the tokens by inheritance, the look and the
* touch flag through this context.
*
* The style is carried in the context as well as on the element, because a
* menu is portalled to `body` and lands outside the canvas. A surface that
@@ -18,7 +19,6 @@ import {
useDashboardLook,
useDashboardPalette,
useDashboardTheme,
useDashboardTouch,
} from "../../settings"
import { rolesOf, tokenStyle } from "./theme"
@@ -47,13 +47,21 @@ export const useLook = () => useContext(LookContext)
* for a dashboard that follows the device: the sets state their own colours
* per theme, and a chart canvas has to be told which one it is drawing in.
* Restating the app's own resolved theme changes nothing when they agree.
*
* `touch` is passed in rather than read off the document, because it belongs to
* the screen rather than to the dashboard: the same document may hang on a
* hallway tablet and on a desk browser. Only `/panel/{id}` has a panel to ask,
* so everywhere else takes the default — a phone still gets the finger-sized
* ladder from the width query in `core.css`.
*/
export function useCanvasRoot(dashboard: DashboardDef_Output | undefined) {
export function useCanvasRoot(
dashboard: DashboardDef_Output | undefined,
touch = false,
) {
const { resolvedTheme } = useTheme()
const stated = useDashboardTheme(dashboard)
const roles = rolesOf(useDashboardPalette(dashboard))
const look = useDashboardLook(dashboard)
const touch = useDashboardTouch(dashboard)
return {
className: stated || (resolvedTheme === "dark" ? "dark" : "light"),
style: (roles ? tokenStyle(roles) : {}) as React.CSSProperties,
@@ -72,12 +80,15 @@ export function useCanvasRoot(dashboard: DashboardDef_Output | undefined) {
*/
export function LookProvider({
dashboard,
touch,
children,
}: {
dashboard: DashboardDef_Output | undefined
/** Whether the panel this is drawn on is touched. See `useCanvasRoot`. */
touch?: boolean
children: React.ReactNode
}) {
const root = useCanvasRoot(dashboard)
const root = useCanvasRoot(dashboard, touch)
return (
<LookContext.Provider
value={{
@@ -16,7 +16,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { ICONS } from "../../icons"
import { ICONS, initials } from "../../icons"
import type {
BackdropProps,
FrameProps,
@@ -110,21 +110,13 @@ export function Frame({
)
}
/** Two letters off the title, so a rail of four reads as four different things. */
function initials(label: string): string {
const words = label.split(/[\s_-]+/).filter(Boolean)
if (words.length === 0) return "?"
if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
return (words[0][0] + words[1][0]).toUpperCase()
}
export function Rail({ entries }: RailProps) {
return (
<nav
aria-label="Dashboards on this panel"
data-testid={TESTID.rail}
className={cn(
"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",
"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
@@ -142,7 +134,7 @@ export function Rail({ entries }: RailProps) {
aria-label={entry.label}
aria-current={entry.active ? "page" : undefined}
data-testid={`panel-rail-${entry.name}`}
className="fx-rail-item relative flex size-10 shrink-0 items-center justify-center text-xs font-medium"
className="dui-rail-item fx-rail-item relative flex size-10 shrink-0 items-center justify-center text-xs font-medium"
>
{entry.active ? (
<motion.span
@@ -15,7 +15,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { ICONS } from "../../icons"
import { ICONS, initials } from "../../icons"
import type {
BackdropProps,
FrameProps,
@@ -143,21 +143,13 @@ export function Frame({
)
}
/** Two letters off the title, so a rail of four reads as four different things. */
function initials(label: string): string {
const words = label.split(/[\s_-]+/).filter(Boolean)
if (words.length === 0) return "?"
if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
return (words[0][0] + words[1][0]).toUpperCase()
}
export function Rail({ entries }: RailProps) {
return (
<nav
aria-label="Dashboards on this panel"
data-testid={TESTID.rail}
className={cn(
"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",
"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
@@ -176,7 +168,7 @@ export function Rail({ entries }: RailProps) {
aria-current={entry.active ? "page" : undefined}
data-testid={`panel-rail-${entry.name}`}
className={cn(
"gl-pressable relative flex size-10 shrink-0 items-center justify-center rounded-full text-xs font-medium",
"dui-rail-item gl-pressable relative flex size-10 shrink-0 items-center justify-center rounded-full text-xs font-medium",
entry.active ? "text-foreground" : "text-muted-foreground",
)}
>
@@ -15,7 +15,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { ICONS } from "../../icons"
import { ICONS, initials } from "../../icons"
import type {
BackdropProps,
FrameProps,
@@ -109,21 +109,13 @@ export function Frame({
)
}
/** Two letters off the title, so a rail of four reads as four different things. */
function initials(label: string): string {
const words = label.split(/[\s_-]+/).filter(Boolean)
if (words.length === 0) return "?"
if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
return (words[0][0] + words[1][0]).toUpperCase()
}
export function Rail({ entries }: RailProps) {
return (
<nav
aria-label="Dashboards on this panel"
data-testid={TESTID.rail}
className={cn(
"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",
"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
@@ -142,7 +134,7 @@ export function Rail({ entries }: RailProps) {
aria-current={entry.active ? "page" : undefined}
data-testid={`panel-rail-${entry.name}`}
className={cn(
"m3-pressable relative flex size-10 shrink-0 items-center justify-center rounded-full text-xs font-medium",
"dui-rail-item m3-pressable relative flex size-10 shrink-0 items-center justify-center rounded-full text-xs font-medium",
entry.active
? "text-card-foreground"
: "text-muted-foreground",
+11 -2
View File
@@ -59,11 +59,16 @@ function PanelRoute() {
})
const stacked = useIsMobile()
const rail = dashboards.length > 1
// Said by the panel rather than by the dashboard: this is the screen, and
// whether it is touched or pointed at is a fact about the screen. A document
// hanging on a hallway tablet and on a desk browser is one document either
// way, and only one of the two has fingers on it.
const touch = panel?.touch === true
// The screen is the dashboard here, so its appearance covers the whole of
// it — the rail and the letterbox around a scaled canvas included. This is
// the surface these settings exist for: a panel in a room has no other way
// to be told what to wear.
const root = useCanvasRoot(dashboard as Dashboard | undefined)
const root = useCanvasRoot(dashboard as Dashboard | undefined, touch)
if (panel && dashboards.length === 0) {
return (
@@ -91,11 +96,15 @@ function PanelRoute() {
)}
style={root.style}
>
<LookProvider dashboard={dashboard as Dashboard | undefined}>
<LookProvider
dashboard={dashboard as Dashboard | undefined}
touch={touch}
>
{dashboard ? (
<PanelSurface
dashboard={dashboard as Dashboard}
stacked={stacked}
touch={touch}
// Drawn on the panel rather than beside it: a screen showing four
// dashboards shows the way between them too.
rail={