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
+4 -2
View File
@@ -143,8 +143,6 @@ SETTING_DTYPES: dict[str, str] = {
# An image drawn under the widgets, by URL. A flow publishing to it is what # An image drawn under the widgets, by URL. A flow publishing to it is what
# a wallpaper that changes looks like here. # a wallpaper that changes looks like here.
"background": "str", "background": "str",
# Bigger controls and no hover states, for a panel that is touched.
"touch": "bool",
} }
@@ -525,6 +523,9 @@ class DashboardSummary(BaseModel):
name: str name: str
title: str = "" title: str = ""
#: The glyph this dashboard draws on a panel's rail, so a list can show it
#: without reading every document.
icon: str = ""
widget_count: int = 0 widget_count: int = 0
has_draft: bool = False has_draft: bool = False
#: Of the working copy, so publishing from a list needs no second read. #: Of the working copy, so publishing from a list needs no second read.
@@ -586,6 +587,7 @@ class DashboardStore:
DashboardSummary( DashboardSummary(
name=defn.name, name=defn.name,
title=defn.title, title=defn.title,
icon=defn.icon,
widget_count=len(defn.widgets), widget_count=len(defn.widgets),
has_draft=defn.has_draft, has_draft=defn.has_draft,
version=defn.version, version=defn.version,
+5
View File
@@ -31,6 +31,11 @@ class PanelDef(BaseModel):
#: rail follows this order. A name that no longer resolves is simply a #: rail follows this order. A name that no longer resolves is simply a
#: dashboard someone deleted; the panel skips it. #: dashboard someone deleted; the panel skips it.
dashboards: list[str] = Field(default_factory=list) dashboards: list[str] = Field(default_factory=list)
#: Bigger controls, a bigger rail and no hover states, for a screen that is
#: touched rather than pointed at. It belongs to the device rather than to
#: any dashboard: the same dashboard may hang on a hallway tablet and on a
#: desk browser, and only one of them has fingers on it.
touch: bool = False
#: Which generation of credential this panel honours. A token names the #: Which generation of credential this panel honours. A token names the
#: nonce it was minted at, so bumping this refuses the screen currently #: nonce it was minted at, so bumping this refuses the screen currently
#: hanging here and leaves the panel, its dashboards and their arrangement #: hanging here and leaves the panel, its dashboards and their arrangement
+32
View File
@@ -86,6 +86,38 @@ def test_assign_and_read_back(
) )
def test_touch_belongs_to_the_panel(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""Whether a screen is touched is a fact about the screen, not the document.
The same dashboard may hang on a hallway tablet and on a desk browser, so
the flag rides on the panel and each one answers for itself.
"""
client.post(f"{DASHBOARDS}/shared", headers=superuser_token_headers)
_panels(
client,
superuser_token_headers,
{
"panels": [
{"id": "wall", "dashboards": ["shared"], "touch": True},
{"id": "desk", "dashboards": ["shared"]},
]
},
)
by_id = {
panel["id"]: panel
for panel in client.get(f"{PREFIX}/", headers=superuser_token_headers).json()[
"panels"
]
}
assert by_id["wall"]["touch"] is True
# Absent is pointed at, which is what a panels file written before this
# field existed comes back as.
assert by_id["desk"]["touch"] is False
def test_duplicate_panel_is_refused( def test_duplicate_panel_is_refused(
client: TestClient, superuser_token_headers: dict[str, str] client: TestClient, superuser_token_headers: dict[str, str]
) -> None: ) -> None:
+9 -9
View File
@@ -155,7 +155,6 @@ set them.
| **Theme** | `System`, `Light` or `Dark` | a `str` message | | **Theme** | `System`, `Light` or `Dark` | a `str` message |
| **Palette** | the dashboard's colours, in order | a `list` message | | **Palette** | the dashboard's colours, in order | a `list` message |
| **Background** | the URL of an image | a `str` message | | **Background** | the URL of an image | a `str` message |
| **Touch** | touch friendly on or off | a `bool` message |
| **Lock** | read-only on or off | a `bool` message | | **Lock** | read-only on or off | a `bool` message |
They all work the same way, and both halves are optional: They all work the same way, and both halves are optional:
@@ -219,11 +218,6 @@ paintable as a reading.
An image drawn under the widgets, covering the canvas. It replaces the ground An image drawn under the widgets, covering the canvas. It replaces the ground
the Glass look brings with it. Bound to a message, a flow decides the picture. the Glass look brings with it. Bound to a message, a flow decides the picture.
### Touch
Bigger controls, and nothing that only happens on hover. A phone gets this
anyway, from its own width; a wall panel has no way to say so for itself.
### Lock ### Lock
Lock is a read-only *surface*, not a permission. The controls stay visible, Lock is a read-only *surface*, not a permission. The controls stay visible,
@@ -243,10 +237,16 @@ A wall tablet has no keyboard, so it pairs.
1. **Dashboards → Panels**, add a panel named after where it hangs, and tick 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 the dashboards it shows. More than one and the screen draws a rail to switch
between them. between them, with each dashboard's own icon on it.
2. Point the device's browser at the link the dialog shows. The device then 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
than on a dashboard because it describes the screen: the same dashboard may
also be open in a browser with a mouse. A phone gets it anyway, from its own
width.
3. Point the device's browser at the link the dialog shows. The device then
displays a six-character code. displays a six-character code.
3. Type that code into the same panel's **Pair device** field. The line under 4. Type that code into the same panel's **Pair device** field. The line under
it names what is holding the code. Check it is the screen you just hung, it names what is holding the code. Check it is the screen you just hung,
because approving adopts whatever answered. The screen picks the credential because approving adopts whatever answered. The screen picks the credential
up within a few seconds and never asks again. up within a few seconds and never asks again.
+10
View File
@@ -556,6 +556,11 @@ export const DashboardSummarySchema = {
title: 'Title', title: 'Title',
default: '' default: ''
}, },
icon: {
type: 'string',
title: 'Icon',
default: ''
},
widget_count: { widget_count: {
type: 'integer', type: 'integer',
title: 'Widget Count', title: 'Widget Count',
@@ -2355,6 +2360,11 @@ export const PanelDefSchema = {
type: 'array', type: 'array',
title: 'Dashboards' title: 'Dashboards'
}, },
touch: {
type: 'boolean',
title: 'Touch',
default: false
},
nonce: { nonce: {
type: 'integer', type: 'integer',
title: 'Nonce', 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 = { export type DashboardSummary = {
name: string; name: string;
title?: string; title?: string;
icon?: string;
widget_count?: number; widget_count?: number;
has_draft?: boolean; has_draft?: boolean;
version?: number; version?: number;
@@ -848,6 +849,7 @@ export type PanelDef = {
id: string; id: string;
title?: string; title?: string;
dashboards?: Array<(string)>; dashboards?: Array<(string)>;
touch?: boolean;
nonce?: number; nonce?: number;
}; };
@@ -1872,6 +1874,12 @@ export type RunsCancelRunData = {
export type RunsCancelRunResponse = (fluksio__api__routes__runs__RunRow); export type RunsCancelRunResponse = (fluksio__api__routes__runs__RunRow);
export type RunsRetryRunData = {
runId: string;
};
export type RunsRetryRunResponse = (fluksio__api__routes__runs__RunRow);
export type RunsReadMetricsData = { export type RunsReadMetricsData = {
name?: string; name?: string;
runId: string; runId: string;
@@ -156,6 +156,7 @@ export function CanvasSurface({
dashboard, dashboard,
dots, dots,
rail, rail,
touch,
children, children,
}: { }: {
dashboard: Dashboard dashboard: Dashboard
@@ -163,6 +164,8 @@ export function CanvasSurface({
dots?: boolean dots?: boolean
/** The dashboard-switching rail, drawn on the panel rather than beside it. */ /** The dashboard-switching rail, drawn on the panel rather than beside it. */
rail?: React.ReactNode rail?: React.ReactNode
/** Whether the panel this canvas hangs on is touched rather than pointed at. */
touch?: boolean
children: (scale: number) => React.ReactNode children: (scale: number) => React.ReactNode
}) { }) {
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
@@ -183,7 +186,7 @@ export function CanvasSurface({
const { width, height } = canvasOf(dashboard) const { width, height } = canvasOf(dashboard)
const scale = Math.min(box.width / width, box.height / height) 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)) const area = areaOf(dashboard, Boolean(rail))
return ( return (
@@ -191,7 +194,7 @@ export function CanvasSurface({
{/* Measured first: a guessed scale would place the whole panel once and {/* Measured first: a guessed scale would place the whole panel once and
then move it. */} then move it. */}
{scale > 0 ? ( {scale > 0 ? (
<LookProvider dashboard={dashboard}> <LookProvider dashboard={dashboard} touch={touch}>
<div <div
data-look={root["data-look"]} data-look={root["data-look"]}
data-touch={root["data-touch"]} data-touch={root["data-touch"]}
@@ -22,6 +22,7 @@ export function PanelSurface({
dashboard, dashboard,
stacked, stacked,
rail, rail,
touch,
}: { }: {
dashboard: Dashboard dashboard: Dashboard
/** A landscape arrangement scaled onto a phone comes out at about a fifth of /** A landscape arrangement scaled onto a phone comes out at about a fifth of
@@ -29,6 +30,8 @@ export function PanelSurface({
stacked?: boolean 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. */
rail?: React.ReactNode rail?: React.ReactNode
/** Whether the screen this hangs on is touched. Stated by the panel route. */
touch?: boolean
}) { }) {
return ( return (
<div className="relative size-full"> <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 // The panel's own surface, scaled to fit. No dots: nothing is being
// arranged here. // arranged here.
<CanvasSurface dashboard={dashboard} rail={rail}> <CanvasSurface dashboard={dashboard} rail={rail} touch={touch}>
{() => <DashboardView dashboard={dashboard} rail={Boolean(rail)} />} {() => <DashboardView dashboard={dashboard} rail={Boolean(rail)} />}
</CanvasSurface> </CanvasSurface>
)} )}
{/* Outside the canvas, so it needs the look stated for it. */} {/* Outside the canvas, so it needs the look stated for it. */}
<LookProvider dashboard={dashboard}> <LookProvider dashboard={dashboard} touch={touch}>
<LockNotice dashboard={dashboard} /> <LockNotice dashboard={dashboard} />
</LookProvider> </LookProvider>
</div> </div>
@@ -9,6 +9,7 @@ import {
type PanelsConfig, type PanelsConfig,
PanelsService, PanelsService,
} from "@/client" } from "@/client"
import { initials, resolveIcon } from "@/components/Dashboard/icons"
import { import {
dashboardsQueryOptions, dashboardsQueryOptions,
panelsQueryOptions, panelsQueryOptions,
@@ -26,6 +27,7 @@ import {
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import useAuth from "@/hooks/useAuth" import useAuth from "@/hooks/useAuth"
import useCustomToast from "@/hooks/useCustomToast" import useCustomToast from "@/hooks/useCustomToast"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -120,6 +122,7 @@ export function PanelsDialog() {
dashboards={known.map((dashboard) => ({ dashboards={known.map((dashboard) => ({
name: dashboard.name, name: dashboard.name,
title: dashboard.title || dashboard.name, title: dashboard.title || dashboard.name,
icon: dashboard.icon ?? "",
}))} }))}
onChange={(next) => replace(panel.id, next)} onChange={(next) => replace(panel.id, next)}
onRemove={() => onRemove={() =>
@@ -196,7 +199,7 @@ function PanelRow({
* links — worth seeing — with the writes turned off rather than a 403. * links — worth seeing — with the writes turned off rather than a 403.
*/ */
canEdit: boolean canEdit: boolean
dashboards: { name: string; title: string }[] dashboards: { name: string; title: string; icon: string }[]
onChange: (next: PanelDef) => void onChange: (next: PanelDef) => void
onRemove: () => void onRemove: () => void
}) { }) {
@@ -294,6 +297,25 @@ function PanelRow({
</Button> </Button>
</div> </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 ? ( {dashboards.length === 0 ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No dashboards to assign yet. No dashboards to assign yet.
@@ -303,6 +325,10 @@ function PanelRow({
{dashboards.map((dashboard) => { {dashboards.map((dashboard) => {
const position = assigned.indexOf(dashboard.name) const position = assigned.indexOf(dashboard.name)
const id = `assign-${panel.id}-${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 ( return (
<label <label
key={dashboard.name} key={dashboard.name}
@@ -316,6 +342,16 @@ function PanelRow({
disabled={!canEdit} disabled={!canEdit}
onCheckedChange={() => toggle(dashboard.name)} 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> <span className="flex-1 truncate">{dashboard.title}</span>
{position >= 0 ? ( {position >= 0 ? (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
@@ -100,6 +100,20 @@ export function resolveIcon(name: string): LucideIcon | null {
return CircleHelp 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. * 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. */ /** How a setting's own value reads in the "nothing is driving it" line. */
function valueLabel(name: SettingName, value: unknown): string { function valueLabel(name: SettingName, value: unknown): string {
if (name === "locked") return value === true ? "read-only" : "editable" 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 === "look") return lookOf(value)
if (name === "background") return value ? "that image" : "no image" if (name === "background") return value ? "that image" : "no image"
if (name === "palette") { if (name === "palette") {
@@ -1560,7 +1559,6 @@ export function DashboardPanel({
const locked = settingOf(dashboard, "locked") const locked = settingOf(dashboard, "locked")
const look = settingOf(dashboard, "look") const look = settingOf(dashboard, "look")
const background = settingOf(dashboard, "background") const background = settingOf(dashboard, "background")
const touch = settingOf(dashboard, "touch")
const paletteSetting = settingOf(dashboard, "palette") const paletteSetting = settingOf(dashboard, "palette")
const palette = parsePalette(paletteSetting.value) const palette = parsePalette(paletteSetting.value)
@@ -1765,28 +1763,6 @@ export function DashboardPanel({
/> />
</PanelSection> </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 <PanelSection
title="Lock" 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." 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", palette: "list",
/** The URL of an image drawn under the widgets. */ /** The URL of an image drawn under the widgets. */
background: "str", background: "str",
/** Bigger controls, for a panel that is touched rather than pointed at. */
touch: "bool",
} as const satisfies Record<string, string> } as const satisfies Record<string, string>
/** The settings this build actually wires up. */ /** The settings this build actually wires up. */
@@ -119,11 +117,6 @@ export function useDashboardBackground(
return typeof value === "string" ? value.trim() : "" 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. * The class that themes a dashboard's own surface, or `""` to follow the app.
* *
@@ -56,6 +56,33 @@
line-height: 1.25; 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 * 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 * 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. * 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 * A dashboard states its look and its colours, and the panel it hangs on says
* element — the canvas root — and everything below reads them from there: * whether it is touched, on one element — the canvas root — and everything
* the tokens by inheritance, the look and the touch flag through this context. * 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 * 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 * menu is portalled to `body` and lands outside the canvas. A surface that
@@ -18,7 +19,6 @@ import {
useDashboardLook, useDashboardLook,
useDashboardPalette, useDashboardPalette,
useDashboardTheme, useDashboardTheme,
useDashboardTouch,
} from "../../settings" } from "../../settings"
import { rolesOf, tokenStyle } from "./theme" 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 * 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. * 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. * 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 { resolvedTheme } = useTheme()
const stated = useDashboardTheme(dashboard) const stated = useDashboardTheme(dashboard)
const roles = rolesOf(useDashboardPalette(dashboard)) const roles = rolesOf(useDashboardPalette(dashboard))
const look = useDashboardLook(dashboard) const look = useDashboardLook(dashboard)
const touch = useDashboardTouch(dashboard)
return { return {
className: stated || (resolvedTheme === "dark" ? "dark" : "light"), className: stated || (resolvedTheme === "dark" ? "dark" : "light"),
style: (roles ? tokenStyle(roles) : {}) as React.CSSProperties, style: (roles ? tokenStyle(roles) : {}) as React.CSSProperties,
@@ -72,12 +80,15 @@ export function useCanvasRoot(dashboard: DashboardDef_Output | undefined) {
*/ */
export function LookProvider({ export function LookProvider({
dashboard, dashboard,
touch,
children, children,
}: { }: {
dashboard: DashboardDef_Output | undefined dashboard: DashboardDef_Output | undefined
/** Whether the panel this is drawn on is touched. See `useCanvasRoot`. */
touch?: boolean
children: React.ReactNode children: React.ReactNode
}) { }) {
const root = useCanvasRoot(dashboard) const root = useCanvasRoot(dashboard, touch)
return ( return (
<LookContext.Provider <LookContext.Provider
value={{ value={{
@@ -16,7 +16,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip" } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ICONS } from "../../icons" import { ICONS, initials } from "../../icons"
import type { import type {
BackdropProps, BackdropProps,
FrameProps, 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) { export function Rail({ entries }: RailProps) {
return ( return (
<nav <nav
aria-label="Dashboards on this panel" aria-label="Dashboards on this panel"
data-testid={TESTID.rail} data-testid={TESTID.rail}
className={cn( 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 // 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 // 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 // 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-label={entry.label}
aria-current={entry.active ? "page" : undefined} aria-current={entry.active ? "page" : undefined}
data-testid={`panel-rail-${entry.name}`} 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 ? ( {entry.active ? (
<motion.span <motion.span
@@ -15,7 +15,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip" } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ICONS } from "../../icons" import { ICONS, initials } from "../../icons"
import type { import type {
BackdropProps, BackdropProps,
FrameProps, 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) { export function Rail({ entries }: RailProps) {
return ( return (
<nav <nav
aria-label="Dashboards on this panel" aria-label="Dashboards on this panel"
data-testid={TESTID.rail} data-testid={TESTID.rail}
className={cn( 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 // 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 // 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 // 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} aria-current={entry.active ? "page" : undefined}
data-testid={`panel-rail-${entry.name}`} data-testid={`panel-rail-${entry.name}`}
className={cn( 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", entry.active ? "text-foreground" : "text-muted-foreground",
)} )}
> >
@@ -15,7 +15,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip" } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ICONS } from "../../icons" import { ICONS, initials } from "../../icons"
import type { import type {
BackdropProps, BackdropProps,
FrameProps, 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) { export function Rail({ entries }: RailProps) {
return ( return (
<nav <nav
aria-label="Dashboards on this panel" aria-label="Dashboards on this panel"
data-testid={TESTID.rail} data-testid={TESTID.rail}
className={cn( 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 // 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 // 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 // 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} aria-current={entry.active ? "page" : undefined}
data-testid={`panel-rail-${entry.name}`} data-testid={`panel-rail-${entry.name}`}
className={cn( 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 entry.active
? "text-card-foreground" ? "text-card-foreground"
: "text-muted-foreground", : "text-muted-foreground",
+11 -2
View File
@@ -59,11 +59,16 @@ function PanelRoute() {
}) })
const stacked = useIsMobile() const stacked = useIsMobile()
const rail = dashboards.length > 1 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 // 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 // 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 // the surface these settings exist for: a panel in a room has no other way
// to be told what to wear. // 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) { if (panel && dashboards.length === 0) {
return ( return (
@@ -91,11 +96,15 @@ function PanelRoute() {
)} )}
style={root.style} style={root.style}
> >
<LookProvider dashboard={dashboard as Dashboard | undefined}> <LookProvider
dashboard={dashboard as Dashboard | undefined}
touch={touch}
>
{dashboard ? ( {dashboard ? (
<PanelSurface <PanelSurface
dashboard={dashboard as Dashboard} dashboard={dashboard as Dashboard}
stacked={stacked} stacked={stacked}
touch={touch}
// Drawn on the panel rather than beside it: a screen showing four // Drawn on the panel rather than beside it: a screen showing four
// dashboards shows the way between them too. // dashboards shows the way between them too.
rail={ rail={
+91 -2
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test" import { expect, type Page, test } from "@playwright/test"
import type { PanelDef } from "../src/client" import type { PanelDef } from "../src/client"
import { api, apiPage, deleteAll } from "./utils/api" import { api, apiPage, deleteAll } from "./utils/api"
@@ -35,7 +35,10 @@ test.beforeAll(async ({ browser }) => {
{ {
id: "emit", id: "emit",
type: "python", type: "python",
provides: [{ name: "level", dtype: "float" }], provides: [
{ name: "level", dtype: "float" },
{ name: "mode", dtype: "str" },
],
}, },
], ],
}, },
@@ -62,6 +65,23 @@ test.beforeAll(async ({ browser }) => {
layout: { lg: { x: 0, y: 0, w: 3, h: 2 } }, layout: { lg: { x: 0, y: 0, w: 3, h: 2 } },
config: { message: `${flowName}.level`, dtype: "float" }, config: { message: `${flowName}.level`, dtype: "float" },
}, },
// Something with a hit target on it, so the touch test can measure a
// control rather than only the rail.
{
id: "mode",
type: "dropdown",
title: "Mode",
layout: { lg: { x: 3, y: 0, w: 4, h: 2 } },
config: {
target: `${flowName}.mode`,
dtype: "str",
style: "segmented",
options: [
{ label: "Eco", value: "eco" },
{ label: "Boost", value: "boost" },
],
},
},
] ]
const put = await ( const put = await (
await api(page, `/dashboards/${name}`, { method: "PUT", data: doc }) await api(page, `/dashboards/${name}`, { method: "PUT", data: doc })
@@ -155,6 +175,75 @@ test("the rail is drawn on the panel, in the panel's own look", async ({
) )
}) })
test("a touched panel grows its rail without widening it", async ({ page }) => {
const open = async () => {
await page.goto(`/panel/${flowName}?d=${first}`)
await page.getByTestId("panel-rail").waitFor({ timeout: 20000 })
}
const sizes = async () => ({
rail: (await page.getByTestId("panel-rail").boundingBox())!,
item: (await page.getByTestId(`panel-rail-${first}`).boundingBox())!,
segment: (await page
.getByTestId("widget-frame")
.filter({ hasText: "Mode" })
.getByRole("button")
.first()
.boundingBox())!,
})
await open()
const pointed = await sizes()
await setTouch(page, true)
await open()
await expect(page.getByTestId("canvas-surface")).toHaveAttribute(
"data-touch",
"",
)
const touched = await sizes()
// The whole point: the column the rail lives in is reserved by RAIL_INSET
// either way, so a wider rail would move the arrangement under it.
expect(
Math.abs(touched.rail.width - pointed.rail.width),
`the rail is ${touched.rail.width.toFixed(1)} touched and ${pointed.rail.width.toFixed(1)} pointed at`,
).toBeLessThan(1)
expect(touched.rail.height, "the rail did not grow taller").toBeGreaterThan(
pointed.rail.height,
)
expect(
touched.item.height,
`a rail button is ${touched.item.height.toFixed(1)} touched and ${pointed.item.height.toFixed(1)} pointed at`,
).toBeGreaterThan(pointed.item.height)
// The widgets are told by the same flag, which used to be the dashboard's.
expect(
touched.segment.height,
"a widget control did not grow with the panel",
).toBeGreaterThan(pointed.segment.height)
// And the arrangement still keeps clear of it.
const tile = (await page.getByTestId("widget-frame").first().boundingBox())!
expect(
tile.x,
"a widget is drawn under the touched rail",
).toBeGreaterThanOrEqual(touched.rail.x + touched.rail.width)
await setTouch(page, false)
})
/** What the Panels dialog writes, as the API sees it. */
async function setTouch(page: Page, touch: boolean) {
const config = await (await api(page, "/panels/")).json()
await api(page, "/panels/", {
method: "PUT",
data: {
panels: (config.panels ?? []).map((panel: PanelDef) =>
panel.id === flowName ? { ...panel, touch } : panel,
),
},
})
}
test("the rail switches the panel between its dashboards", async ({ page }) => { test("the rail switches the panel between its dashboards", async ({ page }) => {
await page.goto(`/panel/${flowName}?d=${first}`) await page.goto(`/panel/${flowName}?d=${first}`)
await page.getByTestId(`panel-rail-${second}`).click() await page.getByTestId(`panel-rail-${second}`).click()
+1 -1
View File
@@ -39,7 +39,7 @@ const KEPT = {
/** Dashboard-wide, and nowhere on the canvas the drag happens on. */ /** Dashboard-wide, and nowhere on the canvas the drag happens on. */
const SETTINGS = { const SETTINGS = {
theme: { value: "dark", message: "", dtype: "str" }, theme: { value: "dark", message: "", dtype: "str" },
touch: { value: true, message: "", dtype: "bool" }, locked: { value: true, message: "", dtype: "bool" },
} }
test.use({ storageState: "playwright/.auth/user.json" }) test.use({ storageState: "playwright/.auth/user.json" })
-31
View File
@@ -620,37 +620,6 @@ test("a chart's cursor follows the pointer", async ({ page }) => {
).toBeLessThan(3) ).toBeLessThan(3)
}) })
test("touch makes the controls bigger without changing what they do", async ({
page,
}) => {
await openPanel(page)
const control = page.getByTestId("widget-frame").filter({ hasText: "Mode" })
const pointer = (await control.getByRole("button").first().boundingBox())!
.height
await setLook(page, dashboardName, { touch: { value: true } })
await openPanel(page)
await expect(page.getByTestId("canvas-surface")).toHaveAttribute(
"data-touch",
"",
)
const touched = (await control.getByRole("button").first().boundingBox())!
.height
expect(
touched,
`a segment is ${touched.toFixed(1)} touched and ${pointer.toFixed(1)} pointed at`,
).toBeGreaterThan(pointer)
// The control is the same control: it still publishes what it always did.
await control.getByRole("button", { name: "Boost" }).click()
await expect(control.getByRole("button", { name: "Boost" })).toHaveAttribute(
"aria-pressed",
"true",
)
await setLook(page, dashboardName, { touch: { value: false } })
})
/** /**
* What a dashboard was told to wear, as a wall panel would be told. * What a dashboard was told to wear, as a wall panel would be told.
* *