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
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:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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" })
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user