Draw the panel as a panel

Five things a wall panel showed that a wall panel should not.

**A tile's body no longer clips.** It scrolled, and a box that scrolls
also cuts whatever crosses its edge — which took the glow off a lit
button at exactly the width where the button filled its tile, and off a
gauge's arc at exactly the height where the dial filled its own. Only
what is written or listed asks for a scroller now; everything else is a
picture drawn to fit, and what overflows is left to the frame, which
clips at the tile's edge where a shadow has already faded out. The
slider's phantom scrollbar goes with it.

**The selector is a selector.** Named for what it does rather than what
it is, and the choice it is holding is held in the dashboard's own
primary — a pill that slides between the options rather than a grey one
that had to be looked for. The stored type is untouched, so no document
changes meaning.

**The arrangement is held off the panel's edges**, by the same distance
it holds between two widgets. The ground is not held off with it: a
background covers the whole panel, and only what is arranged on it has
a margin. No stored panel loses a row to it.

**The rail is drawn on the panel.** It was chrome bolted to the edge of
the screen beside the canvas — in the app's own design rather than the
dashboard's, and on a scaled canvas not even lined up with it. It now
takes a column out of the canvas the way the margin does, scaled with
it and wearing its look. Which cell each widget sits in is unchanged;
only how big a cell is.

Two of these were the same mistake twice: an unlayered rule stating
`position` for everything wearing a class — `.gl-surface` on a rail
placed by a utility, and a blanket lift over every child of a pressable
on a pill placed by `layoutId`. Both now say it one element at a time.
This commit is contained in:
2026-08-24 10:44:53 +02:00
parent fea57064f9
commit 6238728dce
18 changed files with 389 additions and 173 deletions
+13 -68
View File
@@ -12,8 +12,11 @@ Deferring because out of scope is fine, but don't mention deferring than.
### To be sorted ### To be sorted
- FEAT/UI: we promise testing, but currently don't provide an UI for testing e.g. mock values or probing edge cases of a flow. This should be resolved (in a dedicated session); I'm thinking of a "Labs" page, which allows simulating an installation with all the flows (using their draft states) and which allows injecting values or mocking values based on events in the past - FEAT/UI when a dashboard has more widgets than the canvas allows for, the canvas should become scrollable (temporarily) to allow scrolling to the widgets outside the canvas and reordering them
- FEAT/UI: check if PWA (https://whatpwacando.today/) notifications could be used to have a panel sending notifications to the device event bus (or generally using PWA to retrieve e.g. location etc). We could introduce a general concept of having a panel (a device, like a wall panel or a phone where the pwa (dashboard) runs) being effectively a node with various outputs. Then various inputs could trigger actions like authentification (i.e. you get home and get a notification which allows you to authenticate the door unlock), get notified on alarms (native alarm connector) or to query geolocation (check where the user is before turning of all lights) etc - BUG/UI some nodes (especially smaller ones) in the brain graph still don't have a pixel perfect centered ring and node background
- FEAT/UI: make the "Settings" button in the dashboard panel a toggle; such that it becomes stateful and pressing again closes the settings panel
- FEAT/UI: we promise testing, but currently don't provide an UI for testing e.g. mock values or probing edge cases of a flow. I'm thinking of a "Labs" page, which allows simulating an installation with all the flows (using their draft states) and which allows injecting values or mocking values based on events in the past. This should then use the draft version of flows / dashboards. For the flows we could make a tab-style view and re-embedd the flow canvas. For the dashboards we could reuse the icon rail
- FEAT/UI: check if PWA (https://whatpwacando.today/) notifications could be used to have a panel sending notifications to the device event bus (or generally using PWA to retrieve e.g. location etc). We could introduce a general concept of having a panel (a device, like a wall panel or a phone where the pwa (dashboard) runs) being effectively a node with various outputs. Then various inputs could trigger actions like authentification (i.e. you get home and get a notification which allows you to authenticate the door unlock), get notified on alarms (native alarm connector) or to query geolocation (check where the user is before turning of all lights) etc. Another example would be a wall panel with speech in- and output; we could use pwa as an interface here for any tts/stt instance managed through fluksio
- BUG/UI sync the theme state between panels and installations - BUG/UI sync the theme state between panels and installations
- CHORE/UI: a dashboard forced to one theme inside a shell on the other still - CHORE/UI: a dashboard forced to one theme inside a shell on the other still
matches `dark:` utilities, because the variant is `&:is(.dark *)` and the matches `dark:` utilities, because the variant is `&:is(.dark *)` and the
@@ -32,6 +35,10 @@ Deferring because out of scope is fine, but don't mention deferring than.
(`ui/segmented.tsx`, `Common/RangePicker.tsx`, and the flow screens). The (`ui/segmented.tsx`, `Common/RangePicker.tsx`, and the flow screens). The
dashboard no longer shares them — it has its own, one per look — so dashboard no longer shares them — it has its own, one per look — so
unifying the remaining three is now purely an app-side job. unifying the remaining three is now purely an app-side job.
- CHORE/UI: a panel's rail runs the full height of the canvas whatever it
carries, so two dashboards leave most of a tall pill empty. Hugging its
contents and centring would read better; the column it reserves stays the
same either way.
- CHORE/UI: the house panels are laid out for 1280x800 — twelve columns, twelve - CHORE/UI: the house panels are laid out for 1280x800 — twelve columns, twelve
rows. A chart's fixed chrome is now its legend alone: the range picker moved rows. A chart's fixed chrome is now its legend alone: the range picker moved
to a column down the right-hand edge and gave its row back, so a third chart to a column down the right-hand edge and gave its row back, so a third chart
@@ -209,30 +216,16 @@ Deferring because out of scope is fine, but don't mention deferring than.
- FEAT/UI (deferred until MCP lands): add a "bot" icon button to the home view (graph panel) which opens a chat window (reuse general concept of a side panel like in flows/nodes to make it a chat panel which can open on any screen (stacks below any other existing panel -> introduce stacking) to give support on errors/write code, generate dashboards etc) to explain the error(s) - FEAT/UI (deferred until MCP lands): add a "bot" icon button to the home view (graph panel) which opens a chat window (reuse general concept of a side panel like in flows/nodes to make it a chat panel which can open on any screen (stacks below any other existing panel -> introduce stacking) to give support on errors/write code, generate dashboards etc) to explain the error(s)
- FEAT/UI make the header (Fluksio - YEAR) and the logo in the sidebar link to the main page (fluksio.com) - FEAT/UI make the header (Fluksio - YEAR) and the logo in the sidebar link to the main page (fluksio.com)
- FEAT/UI consider adding a diagram to the Home view which shows a histogram of the different classes of nodes and which time it takes to execute (logarithmic scale); this should give a hint on the load and help to detect bottle necks/hotspots - FEAT/UI consider adding a diagram to the Home view which shows a histogram of the different classes of nodes and which time it takes to execute (logarithmic scale); this should give a hint on the load and help to detect bottle necks/hotspots
- CHORE/API: revoking an OAuth client does not invalidate access tokens already issued; they are stateless JWTs valid up to `MCP_TOKEN_EXPIRE_MINUTES`. Immediate revocation means `app/mcp/http.py` checking the client row still exists.
- CHORE/FLOW: `Pipeline.trigger`'s docstring says a paused flow still publishes so the value shows on the canvas. True only without a queue; with one the item parks before `apply_outputs` and nothing shows. Docstring and behaviour disagree.
### Persistence and databases ### Persistence and databases
From the 2026-08 database review. Verdict recorded under Deferred: the
Postgres + Redis + git-files split stays; the actionable part is durability.
- CHORE/INFRA: Redis AOF runs at `appendfsync everysec`, so up to ~1 s of journaled work-queue entries can vanish on a crash — softer than "journaled before it runs" reads. Queue write volume is low, so `appendfsync always` is likely affordable; otherwise document the loss window. - CHORE/INFRA: Redis AOF runs at `appendfsync everysec`, so up to ~1 s of journaled work-queue entries can vanish on a crash — softer than "journaled before it runs" reads. Queue write volume is low, so `appendfsync always` is likely affordable; otherwise document the loss window.
- CHORE/INFRA: Redis has no auth (`requirepass` unset). Fine on the compose-internal network; a blocker for M5 remote workers, which turn Redis into a network-exposed shared bus. - CHORE/INFRA: Redis has no auth (`requirepass` unset). Fine on the compose-internal network; a blocker for M5 remote workers, which turn Redis into a network-exposed shared bus.
### Connector write paths ### Connector write paths
Needs someone watching the real hardware, so it is not a background task. This
is what M4 still waits on, together with porting the flows.
Art-Net can write now: `ConnectorNode.write` carries a node's input ports, a
per-port `channels` map places each on its own DMX channel, and `transmit`
still gates the socket. Verified on the wire against a listener (channel 33 =
255, channel 31 = 60, nothing else set) and the MQTT half was driven end to
end against the house broker. The rig was the `house_control` flow and its
dashboard; it has been deleted now that `dmx` owns the universe, and
`make -C app seed-house` rebuilds it if that verification is ever wanted
again.
- FEAT/NODE: Art-Net against the real fixtures is still untried. The house's own dmxnet sender re-emits universe 1 every 1000 ms, so fluksio and Node-RED overwrite each other; the test needs Node-RED's Art-Net sender stopped, and while it is stopped every channel fluksio does not set is dark.
- CHORE/NODE: the operatorId worry was unfounded — the reference Node-RED `setstat` node for this unit is configured with an empty operatorId and deviceId, so a command needs no registration. The second unit may still differ. - CHORE/NODE: the operatorId worry was unfounded — the reference Node-RED `setstat` node for this unit is configured with an empty operatorId and deviceId, so a command needs no registration. The second unit may still differ.
- PERF/NODE: a `wfrac` command is two round trips (read, then set) on the scheduler's thread, so at the default timeout a command can hold a cascade for several seconds. Fine for a person pressing a button; a flow commanding it on a schedule would want the work off that thread. - PERF/NODE: a `wfrac` command is two round trips (read, then set) on the scheduler's thread, so at the default timeout a command can hold a cascade for several seconds. Fine for a person pressing a button; a flow commanding it on a schedule would want the work off that thread.
- CHORE/NODE: `wfrac` writes carry the unit's whole state, so two flows commanding one unit will each undo whatever the other set between their read and their write. One writer per unit, the same rule Art-Net has for a universe. - CHORE/NODE: `wfrac` writes carry the unit's whole state, so two flows commanding one unit will each undo whatever the other set between their read and their write. One writer per unit, the same rule Art-Net has for a universe.
@@ -243,26 +236,11 @@ again.
### Porting the Node-RED flows ### Porting the Node-RED flows
Built: `scripts/tinyhouse/`, `make -C app seed-tinyhouse`. Nineteen flows, 109
nodes, three dashboards, against the reference's 865. What each requirement
became, what was deliberately dropped, and the cutover order are in
`docs/private/node-red-transition.md`. What is left here is what the seeding
does not settle.
- CHORE/FLOW: the DMX channel collisions were decided rather than ported — ch 9
is one bathroom fixture, and `light/traverseAmbientLight` (28-30, colliding
with the window opener's 28-29) is left out on the assumption that it is
decommissioned, since the live scene engine never drove it. The stray 1CH
mapping on 129 beside the awning's 129-130 is dropped. **Check the wiring
before `dmx` transmits.**
- CHORE/FLOW: 1CH values are still used raw, so those fixtures still never go - CHORE/FLOW: 1CH values are still used raw, so those fixtures still never go
above 100/255, and the 4CH master channel still takes `v` on a different above 100/255, and the 4CH master channel still takes `v` on a different
scale from its colour channels. Both are `scale` and `master_raw` settings on scale from its colour channels. Both are `scale` and `master_raw` settings on
the encoders now, so fixing one is a decision about one fixture rather than a the encoders now, so fixing one is a decision about one fixture rather than a
surprise across all of them. surprise across all of them.
- CHORE/FLOW: `artnet.baseline` in `house.json` is empty. A frame carries the
whole universe, so the first one this node sends darkens every channel it is
not driving. Fill it in from what the fixtures are at, before transmitting.
- FEAT/FLOW: the deferred half of the port — the media plug and the radio at - FEAT/FLOW: the deferred half of the port — the media plug and the radio at
the media plug's address, the alarm clock, audio through `thgui/display/audio`, and the the media plug's address, the alarm clock, audio through `thgui/display/audio`, and the
fire alarm. The alarm's hook exists: every arbiter takes a `force_at` pulse, fire alarm. The alarm's hook exists: every arbiter takes a `force_at` pulse,
@@ -280,10 +258,7 @@ does not settle.
nodes speak to the house broker here. Either a shared client registry or a nodes speak to the house broker here. Either a shared client registry or a
correction to the sentence. correction to the sentence.
### Bugs found while building the screens
- CHORE/API: revoking an OAuth client does not invalidate access tokens already issued; they are stateless JWTs valid up to `MCP_TOKEN_EXPIRE_MINUTES`. Immediate revocation means `app/mcp/http.py` checking the client row still exists.
- CHORE/FLOW: `Pipeline.trigger`'s docstring says a paused flow still publishes so the value shows on the canvas. True only without a queue; with one the item parks before `apply_outputs` and nothing shows. Docstring and behaviour disagree.
### Out-of-process nodes and modules ### Out-of-process nodes and modules
@@ -307,32 +282,7 @@ does not settle.
- PERF/API: `queue.stats()` does a keyspace `scan_iter` on every call while two endpoints poll it. - PERF/API: `queue.stats()` does a keyspace `scan_iter` on every call while two endpoints poll it.
- CHORE/INFRA: dev only — memory-queue ids (`mem-{seq}`) restart at 0 each boot and `FlowRun.id` is the primary key, so a restart without Redis upserts over the previous boot's run rows. - CHORE/INFRA: dev only — memory-queue ids (`mem-{seq}`) restart at 0 each boot and `FlowRun.id` is the primary key, so a restart without Redis upserts over the previous boot's run rows.
### Wall-panel parity with the current home dashboard ### Wall-panel
What a fluksio dashboard still lacks to replace `geli-dash` (Dash/Plotly, e-ink
panel: clock and nav chrome, indoor climate, weather forecast strip, calendar
agenda, room light groups, sliders, power/battery bars, and three pages of
InfluxDB time series). Component-level only; the arrangement and the styling are
this design system's business, not that one's.
Decisions taken up front, because most items below depend on them:
- Structured data reaches a widget as a *declared shape*, not as opaque JSON with
a path per binding. A path would leave the picker with nothing to offer and
`widgetIssue` unable to judge a tile from the document alone.
- BUG/UI double check that this aligns with the new data-science pipeline feature
- A chart asks a flow for its series the way every other input widget speaks:
it publishes a request message and reads the answer. No query API, no
database knowledge in the widget.
- Database nodes are transport and credentials only. The InfluxDB node runs the
Flux it is handed and echoes back every other field of the request; building
the query and shaping the answer are Python nodes either side of it. That is
what keeps a widget ignorant of the database, and it is also what a series
read mode inside the node would have prevented. A "grouped nodes" concept
could later package the standard chart→build→db→parse→chart quintet so a
dashboard is not five nodes of wiring each time.
- Nothing e-ink-specific in the widgets. Panel access is a credential problem
(see below); the display's demands are a rendering profile, deferred.
- CHORE/UI: identical in-flight chart requests are deduplicated per browser tab, - CHORE/UI: identical in-flight chart requests are deduplicated per browser tab,
so two wall panels showing the same tile still run the query twice. An so two wall panels showing the same tile still run the query twice. An
@@ -348,12 +298,7 @@ Decisions taken up front, because most items below depend on them:
answered by flow inputs. What is left is the naming: an input a panel writes answered by flow inputs. What is left is the naming: an input a panel writes
looks the same as one a run passes in. looks the same as one a run passes in.
Deliberately not ported: the local-state/timestamp reconciliation the old ### Dashboard
dashboard does per widget — publishing on release and reading the value back
covers it — and its demo mode, since an unbound or silent message already renders
as an em dash.
### Dashboard follow-ups
- FEAT/NODE: the hosted demo places six of the fifteen built-in node types (`python`, `inject`, `change`, `join`, `rbe`, `trigger`); it does cover all fifteen dashboard widget types. `switch` and `delay` are the awkward ones — a `switch` branch needs either a dead-end port or trivial nodes to turn a branch back into a label, and neither reads as something a person would hang — while the I/O types (`mqtt`, `http`, `influxdb`, `exec`, `file`, `ntfy`, `mlp`) are unplaced because the demo has nothing real to talk to. Worth revisiting when the demo grows a second page. - FEAT/NODE: the hosted demo places six of the fifteen built-in node types (`python`, `inject`, `change`, `join`, `rbe`, `trigger`); it does cover all fifteen dashboard widget types. `switch` and `delay` are the awkward ones — a `switch` branch needs either a dead-end port or trivial nodes to turn a branch back into a label, and neither reads as something a person would hang — while the I/O types (`mqtt`, `http`, `influxdb`, `exec`, `file`, `ntfy`, `mlp`) are unplaced because the demo has nothing real to talk to. Worth revisiting when the demo grows a second page.
- CHORE/UI: multi-page and multi-section dashboards have no UI and need none — a panel carries - CHORE/UI: multi-page and multi-section dashboards have no UI and need none — a panel carries
+1 -1
View File
@@ -49,7 +49,7 @@ screen reader calls its controls, and what a published value is labelled with.
| **Switch** | `bool` | on/off | | **Switch** | `bool` | on/off |
| **Slider** | `float`, `int` | min, max, step | | **Slider** | `float`, `int` | min, max, step |
| **Input** | text or a number | free entry | | **Input** | text or a number | free entry |
| **Dropdown** | one of a list | a mode, a scene, a preset | | **Selector** | one of a list | a mode, a scene, a preset — as a menu, or as a row of choices with the active one held |
| **Colour** | `[h, s, v]`, `[r, g, b]` or `"#rrggbb"` | a hue wheel with saturation and brightness, for an RGB fixture | | **Colour** | `[h, s, v]`, `[r, g, b]` or `"#rrggbb"` | a hue wheel with saturation and brightness, for an RGB fixture |
The colour wheel sends `[h, s, v]` by default — hue 0-360 degrees, saturation The colour wheel sends `[h, s, v]` by default — hue 0-360 degrees, saturation
@@ -54,8 +54,8 @@ import { appPath } from "@/lib/portal"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { handleError } from "@/utils" import { handleError } from "@/utils"
import { import {
areaOf,
CanvasSurface, CanvasSurface,
canvasOf,
columnsOf, columnsOf,
type Dashboard, type Dashboard,
DashboardView, DashboardView,
@@ -69,7 +69,7 @@ import {
sectionsOf, sectionsOf,
widgetsOf, widgetsOf,
} from "./DashboardView" } from "./DashboardView"
import { PanelRail, RAIL_INSET } from "./PanelRail" import { PanelRail } from "./PanelRail"
import { DashboardPanel, WidgetPanel } from "./panels" import { DashboardPanel, WidgetPanel } from "./panels"
import { import {
dashboardKeys, dashboardKeys,
@@ -327,7 +327,6 @@ export function DashboardEditor({
// Every widget the panel shows, not just the first section's — a document // Every widget the panel shows, not just the first section's — a document
// written with several sections is one arrangement here, as it is there. // written with several sections is one arrangement here, as it is there.
const widgets = page ? flatWidgets(page) : [] const widgets = page ? flatWidgets(page) : []
const canvas = canvasOf(draft)
const updateWidgets = (next: WidgetDef[]) => { const updateWidgets = (next: WidgetDef[]) => {
if (!page) return if (!page) return
@@ -381,8 +380,25 @@ export function DashboardEditor({
), ),
) )
// A dashboard hanging on a panel beside others is drawn with a rail on it,
// which takes room off the canvas. Show that here rather than letting someone
// arrange against a width the wall does not have.
// ponytail: the first such panel wins — the others differ only in scale.
const { data: panels } = useQuery(panelsQueryOptions())
const railDashboards =
(panels?.panels ?? []).find(
(panel) =>
(panel.dashboards ?? []).length > 1 &&
(panel.dashboards ?? []).includes(draft.name),
)?.dashboards ?? null
const hasRail = Boolean(railDashboards) && !stacked
// What the arrangement is laid out in: the canvas less its margin, and less
// the rail's column when the panel carries one.
const area = areaOf(draft, hasRail)
const layout = layoutOf(widgets, columns) const layout = layoutOf(widgets, columns)
const rows = rowsOf(draft) const rows = rowsOf(draft, hasRail)
// maxRows below only constrains a *drag*. A stored placement is corrected // maxRows below only constrains a *drag*. A stored placement is corrected
// against `cols` and nothing else, so a canvas that shrank leaves whatever // against `cols` and nothing else, so a canvas that shrank leaves whatever
// now falls past its bottom edge drawn under the clip. // now falls past its bottom edge drawn under the clip.
@@ -428,18 +444,6 @@ export function DashboardEditor({
const active = widgets.find((widget) => widget.id === selected) ?? null const active = widgets.find((widget) => widget.id === selected) ?? null
const panelOpen = Boolean(active) || settingsOpen const panelOpen = Boolean(active) || settingsOpen
// A dashboard hanging on a panel beside others is drawn with a rail over it,
// which takes room off the canvas. Show that here rather than letting someone
// arrange against a width the wall does not have.
// ponytail: the first such panel wins — the others differ only in scale.
const { data: panels } = useQuery(panelsQueryOptions())
const railDashboards =
(panels?.panels ?? []).find(
(panel) =>
(panel.dashboards ?? []).length > 1 &&
(panel.dashboards ?? []).includes(draft.name),
)?.dashboards ?? null
const setEdit = (next: boolean) => { const setEdit = (next: boolean) => {
setSelected(null) setSelected(null)
setSettingsOpen(false) setSettingsOpen(false)
@@ -492,19 +496,37 @@ export function DashboardEditor({
/> />
</div> </div>
) : ( ) : (
<CanvasSurface dashboard={draft} dots={edit}> <CanvasSurface
dashboard={draft}
dots={edit}
rail={
railDashboards ? (
<PanelRail
dashboards={railDashboards}
current={draft.name}
// Editing one dashboard of a panel and editing its neighbour are
// the same job, so the rail keeps whichever mode this one is in.
linkFor={(name) => ({
to: "/dashboards/$name",
params: { name },
search: edit ? { edit: true } : {},
})}
/>
) : null
}
>
{(scale) => {(scale) =>
!edit ? ( !edit ? (
<DashboardView dashboard={draft} /> <DashboardView dashboard={draft} rail={hasRail} />
) : ( ) : (
<GridLayout <GridLayout
width={canvas.width} width={area.width}
layout={layout} layout={layout}
onLayoutChange={applyLayout} onLayoutChange={applyLayout}
positionStrategy={scaledStrategy(scale)} positionStrategy={scaledStrategy(scale)}
gridConfig={{ gridConfig={{
cols: columns, cols: columns,
rowHeight: rowHeightOf(draft), rowHeight: rowHeightOf(draft, hasRail),
margin: [GRID_GAP, GRID_GAP], margin: [GRID_GAP, GRID_GAP],
containerPadding: [0, 0], containerPadding: [0, 0],
// The canvas is the constraint: nothing may be dragged off the // The canvas is the constraint: nothing may be dragged off the
@@ -534,28 +556,11 @@ export function DashboardEditor({
return ( return (
<LookProvider dashboard={draft}> <LookProvider dashboard={draft}>
{railDashboards && !stacked ? (
<PanelRail
dashboards={railDashboards}
current={draft.name}
// Editing one dashboard of a panel and editing its neighbour are the
// same job, so the rail keeps whichever mode this one is in.
linkFor={(name) => ({
to: "/dashboards/$name",
params: { name },
search: edit ? { edit: true } : {},
})}
/>
) : null}
<div <div
className={cn( className={cn(
"absolute inset-0 overflow-hidden px-4 pb-24 pt-20 transition-[padding] duration-200", "absolute inset-0 overflow-hidden px-4 pb-24 pt-20 transition-[padding] duration-200",
panelOpen && "md:pr-[27rem]", panelOpen && "md:pr-[27rem]",
)} )}
style={
railDashboards && !stacked ? { paddingLeft: RAIL_INSET } : undefined
}
data-testid="dashboard-canvas" data-testid="dashboard-canvas"
> >
<PaletteProvider dashboard={draft}>{body}</PaletteProvider> <PaletteProvider dashboard={draft}>{body}</PaletteProvider>
@@ -566,7 +571,6 @@ export function DashboardEditor({
"pointer-events-none absolute inset-0 transition-[right] duration-200", "pointer-events-none absolute inset-0 transition-[right] duration-200",
panelOpen && "md:right-[27rem]", panelOpen && "md:right-[27rem]",
)} )}
style={railDashboards && !stacked ? { left: RAIL_INSET } : undefined}
> >
<CanvasTitle> <CanvasTitle>
<span className="truncate px-3 py-1.5 text-sm font-medium"> <span className="truncate px-3 py-1.5 text-sm font-medium">
@@ -16,7 +16,7 @@ import {
useDashboardBackground, useDashboardBackground,
} from "./settings" } from "./settings"
import { useUi } from "./ui" import { useUi } from "./ui"
import { showTitle } from "./ui/core/config" import { scrolls, showTitle } from "./ui/core/config"
import { LookProvider, useCanvasRoot, useLook } from "./ui/core/look" import { LookProvider, useCanvasRoot, useLook } from "./ui/core/look"
import { gridStagger, LOOK } from "./ui/core/motion" import { gridStagger, LOOK } from "./ui/core/motion"
import { WidgetBody, widgetIssue } from "./widgets" import { WidgetBody, widgetIssue } from "./widgets"
@@ -44,6 +44,26 @@ export const CANVAS_PRESETS = [
/** The gap between widgets, in pixels. Matches the `gap-3` view mode uses. */ /** The gap between widgets, in pixels. Matches the `gap-3` view mode uses. */
export const GRID_GAP = 12 export const GRID_GAP = 12
/**
* How far the arrangement keeps from the panel's own edges.
*
* The same as the gap between two widgets, so a tile sits as far from the edge
* of the screen as it does from its neighbour. The ground is not inset with it
* — a background covers the whole panel, and only what is arranged on it is
* held off the edge.
*/
export const GRID_INSET = GRID_GAP
/**
* The column a dashboard-switching rail takes out of the canvas, in canvas
* pixels.
*
* The rail is drawn on the panel rather than beside it — it is part of what
* the screen shows — so it is inside the canvas, scaled with it, wearing the
* dashboard's own look.
*/
export const RAIL_INSET = 72
/** /**
* The generated client marks every list optional, because the server fills * The generated client marks every list optional, because the server fills
* them in. These three keep that from spreading through the components. * them in. These three keep that from spreading through the components.
@@ -80,22 +100,43 @@ const DEFAULT_COLUMN_WIDTH =
*/ */
const CELL_ASPECT = DEFAULT_COLUMN_WIDTH / DEFAULT_ROW_HEIGHT const CELL_ASPECT = DEFAULT_COLUMN_WIDTH / DEFAULT_ROW_HEIGHT
const columnWidthOf = (dashboard: Dashboard) => /**
(canvasOf(dashboard).width + GRID_GAP) / columnsOf(dashboard) - GRID_GAP * The part of the canvas an arrangement is laid out in.
*
* The margin off every edge, plus the rail's column when the panel carries
* one: a rail is part of the screen rather than something beside it, so the
* room it takes comes out of the canvas the same way the margin does. Which
* cell each widget sits in never changes — only how big a cell is.
*/
export const areaOf = (dashboard: Dashboard, rail = false) => {
const { width, height } = canvasOf(dashboard)
const left = GRID_INSET + (rail ? RAIL_INSET : 0)
return {
left,
top: GRID_INSET,
width: Math.max(1, width - left - GRID_INSET),
height: Math.max(1, height - 2 * GRID_INSET),
}
}
const columnWidthOf = (dashboard: Dashboard, rail = false) =>
(areaOf(dashboard, rail).width + GRID_GAP) / columnsOf(dashboard) - GRID_GAP
/** One grid row, in pixels — the unit widget heights are multiples of. */ /** One grid row, in pixels — the unit widget heights are multiples of. */
export const rowHeightOf = (dashboard: Dashboard) => export const rowHeightOf = (dashboard: Dashboard, rail = false) =>
Math.round(columnWidthOf(dashboard) / CELL_ASPECT) Math.round(columnWidthOf(dashboard, rail) / CELL_ASPECT)
/** Distance between two row origins — what a widget's `y` is counted in. */ /** Distance between two row origins — what a widget's `y` is counted in. */
export const rowPitchOf = (dashboard: Dashboard) => export const rowPitchOf = (dashboard: Dashboard, rail = false) =>
rowHeightOf(dashboard) + GRID_GAP rowHeightOf(dashboard, rail) + GRID_GAP
/** How many rows of the grid fit in the canvas; the rest is off the panel. */ /** How many rows of the grid fit in the canvas; the rest is off the panel. */
export const rowsOf = (dashboard: Dashboard) => export const rowsOf = (dashboard: Dashboard, rail = false) =>
Math.max( Math.max(
1, 1,
Math.floor((canvasOf(dashboard).height + GRID_GAP) / rowPitchOf(dashboard)), Math.floor(
(areaOf(dashboard, rail).height + GRID_GAP) / rowPitchOf(dashboard, rail),
),
) )
/** /**
@@ -114,11 +155,14 @@ export const rowsOf = (dashboard: Dashboard) =>
export function CanvasSurface({ export function CanvasSurface({
dashboard, dashboard,
dots, dots,
rail,
children, children,
}: { }: {
dashboard: Dashboard dashboard: Dashboard
/** Draw the placement grid. A wall panel is not being arranged, so: no. */ /** Draw the placement grid. A wall panel is not being arranged, so: no. */
dots?: boolean dots?: boolean
/** The dashboard-switching rail, drawn on the panel rather than beside it. */
rail?: React.ReactNode
children: (scale: number) => React.ReactNode children: (scale: number) => React.ReactNode
}) { }) {
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
@@ -140,6 +184,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)
const area = areaOf(dashboard, Boolean(rail))
return ( return (
<div ref={ref} className="relative size-full overflow-hidden"> <div ref={ref} className="relative size-full overflow-hidden">
@@ -165,20 +210,33 @@ export function CanvasSurface({
top: (box.height - height * scale) / 2, top: (box.height - height * scale) / 2,
transform: `scale(${scale})`, transform: `scale(${scale})`,
transformOrigin: "top left", transformOrigin: "top left",
// One dot per cell corner. The column pitch the grid snaps to // One dot per cell corner, counted across the arranged area
// is (width + gap) / columns; a row is one row plus the gap. // rather than the whole panel: the margin is not a cell.
"--dot-x": `${(width + GRID_GAP) / columnsOf(dashboard)}px`, "--dot-x": `${(area.width + GRID_GAP) / columnsOf(dashboard)}px`,
"--dot-y": `${rowPitchOf(dashboard)}px`, "--dot-y": `${rowPitchOf(dashboard, Boolean(rail))}px`,
} as React.CSSProperties } as React.CSSProperties
} }
> >
{/* Full bleed, and under everything: a background covers the
panel, margin included. */}
<CanvasGround dashboard={dashboard} /> <CanvasGround dashboard={dashboard} />
{/* Over the ground rather than under it: the dots are what is {rail}
being arranged against, and a wallpaper must not hide them. */} <div
{dots ? ( className="absolute"
<div className="dot-canvas pointer-events-none absolute inset-0" /> style={{
) : null} left: area.left,
{children(scale)} top: area.top,
width: area.width,
height: area.height,
}}
>
{/* Over the ground rather than under it: the dots are what is
being arranged against, and a wallpaper must not hide them. */}
{dots ? (
<div className="dot-canvas pointer-events-none absolute inset-0" />
) : null}
{children(scale)}
</div>
</div> </div>
</LookProvider> </LookProvider>
) : null} ) : null}
@@ -293,11 +351,14 @@ export function DashboardView({
dashboard, dashboard,
renderWidget, renderWidget,
stacked, stacked,
rail,
}: { }: {
dashboard: Dashboard dashboard: Dashboard
renderWidget?: (widget: WidgetDef) => React.ReactNode renderWidget?: (widget: WidgetDef) => React.ReactNode
/** One column at the viewport's width, for a phone. */ /** One column at the viewport's width, for a phone. */
stacked?: boolean stacked?: boolean
/** Whether the panel carries a rail, which takes a column of the canvas. */
rail?: boolean
}) { }) {
// ponytail: the first page only. A panel carries several whole dashboards // ponytail: the first page only. A panel carries several whole dashboards
// — that is what the rail is for — so nothing writes a second page. // — that is what the rail is for — so nothing writes a second page.
@@ -343,6 +404,7 @@ export function DashboardView({
widgets={widgets} widgets={widgets}
columns={columns} columns={columns}
stacked={stacked} stacked={stacked}
rail={rail}
renderWidget={renderWidget} renderWidget={renderWidget}
/> />
</PaletteProvider> </PaletteProvider>
@@ -363,12 +425,14 @@ function WidgetGrid({
widgets, widgets,
columns, columns,
stacked, stacked,
rail,
renderWidget, renderWidget,
}: { }: {
dashboard: Dashboard dashboard: Dashboard
widgets: WidgetDef[] widgets: WidgetDef[]
columns: number columns: number
stacked?: boolean stacked?: boolean
rail?: boolean
renderWidget?: (widget: WidgetDef) => React.ReactNode renderWidget?: (widget: WidgetDef) => React.ReactNode
}) { }) {
const { Frame } = useUi() const { Frame } = useUi()
@@ -385,7 +449,7 @@ function WidgetGrid({
"--widget-cols": columns, "--widget-cols": columns,
// The row height follows the canvas, so the CSS grid and the // The row height follows the canvas, so the CSS grid and the
// editor's grid library cannot drift apart. // editor's grid library cannot drift apart.
"--row-height": `${rowHeightOf(dashboard)}px`, "--row-height": `${rowHeightOf(dashboard, rail)}px`,
} as React.CSSProperties } as React.CSSProperties
} }
> >
@@ -403,6 +467,7 @@ function WidgetGrid({
<Frame <Frame
title={showTitle(widget) ? widget.title : undefined} title={showTitle(widget) ? widget.title : undefined}
issue={widgetIssue(widget)} issue={widgetIssue(widget)}
scroll={scrolls(widget)}
> >
<WidgetBody widget={widget} dashboard={dashboard.name} /> <WidgetBody widget={widget} dashboard={dashboard.name} />
</Frame> </Frame>
@@ -4,10 +4,6 @@ import type { LinkProps } from "@tanstack/react-router"
import { dashboardQueryOptions } from "@/components/Dashboard/queries" import { dashboardQueryOptions } from "@/components/Dashboard/queries"
import { useUi } from "./ui" import { useUi } from "./ui"
/** How much room the rail takes, canvas insets included. Mirrors the side
* panel's `27rem`: the button plus the gutters either side of it. */
export const RAIL_INSET = "4.5rem"
/** /**
* Switching between the dashboards one panel was assigned. * Switching between the dashboards one panel was assigned.
* *
@@ -21,11 +21,14 @@ import { LookProvider } from "@/components/Dashboard/ui/core/look"
export function PanelSurface({ export function PanelSurface({
dashboard, dashboard,
stacked, stacked,
rail,
}: { }: {
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
* its size, which reads as nothing at all. Stack it instead. */ * its size, which reads as nothing at all. Stack it instead. */
stacked?: boolean stacked?: boolean
/** The way between this panel's dashboards, drawn on the canvas itself. */
rail?: React.ReactNode
}) { }) {
return ( return (
<div className="relative size-full"> <div className="relative size-full">
@@ -34,8 +37,8 @@ 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}> <CanvasSurface dashboard={dashboard} rail={rail}>
{() => <DashboardView dashboard={dashboard} />} {() => <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. */}
@@ -67,6 +67,19 @@ export const fractionOf = (
export const showTitle = (widget: WidgetDef): boolean => export const showTitle = (widget: WidgetDef): boolean =>
config(widget).show_title !== false config(widget).show_title !== false
/**
* Whether this widget's body may scroll.
*
* Only what is written or listed: a note is as long as it is, and an agenda
* has as many entries as the week does. Everything else is a picture drawn to
* fit the tile, and a picture that scrolls is one nobody can see the rest of —
* as well as a box that clips whatever glow or shadow crosses its edge.
*/
export const scrolls = (widget: WidgetDef): boolean =>
widget.type === "markdown" ||
widget.type === "agenda" ||
widget.type === "notification"
/** Radix hands back a string; the message wants whatever was configured. */ /** Radix hands back a string; the message wants whatever was configured. */
export function asOriginal( export function asOriginal(
selected: string, selected: string,
@@ -36,6 +36,8 @@ export type FrameProps = {
issue?: string | null issue?: string | null
/** Make this widget draggable in the editor. */ /** Make this widget draggable in the editor. */
grip?: boolean grip?: boolean
/** Let the body scroll. Only what is written or listed asks for it. */
scroll?: boolean
selected?: boolean selected?: boolean
onClick?: React.MouseEventHandler<HTMLDivElement> onClick?: React.MouseEventHandler<HTMLDivElement>
children: React.ReactNode children: React.ReactNode
@@ -51,7 +51,7 @@ export function Button({
onClick={onClick} onClick={onClick}
> >
<span className="gl-glow" aria-hidden /> <span className="gl-glow" aria-hidden />
<span className="min-w-0 truncate">{children}</span> <span className="gl-lift min-w-0 truncate">{children}</span>
</motion.button> </motion.button>
) )
} }
@@ -181,11 +181,14 @@ export function Select({
className="gl-field gl-pressable justify-between" className="gl-field gl-pressable justify-between"
> >
<span className="gl-glow" aria-hidden /> <span className="gl-glow" aria-hidden />
<span className="min-w-0 truncate"> <span className="gl-lift min-w-0 truncate">
<SelectPrimitive.Value placeholder="Choose" /> <SelectPrimitive.Value placeholder="Choose" />
</span> </span>
<SelectPrimitive.Icon asChild> <SelectPrimitive.Icon asChild>
<ChevronDown className="size-4 shrink-0 opacity-60" aria-hidden /> <ChevronDown
className="gl-lift size-4 shrink-0 opacity-60"
aria-hidden
/>
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
<SelectPrimitive.Portal> <SelectPrimitive.Portal>
@@ -94,6 +94,7 @@ export function Frame({
title, title,
issue, issue,
grip, grip,
scroll,
selected, selected,
onClick, onClick,
children, children,
@@ -121,7 +122,9 @@ export function Frame({
{issue ? <Issue issue={issue} /> : null} {issue ? <Issue issue={issue} /> : null}
</div> </div>
) : null} ) : null}
<div className="dui-frame-body">{children}</div> <div className={cn("dui-frame-body", scroll && "dui-frame-scroll")}>
{children}
</div>
{/* No header to sit in, so the fault takes the corner instead. */} {/* No header to sit in, so the fault takes the corner instead. */}
{!title && issue ? ( {!title && issue ? (
<div className="dui-frame-corner"> <div className="dui-frame-corner">
@@ -155,7 +158,7 @@ export function Rail({ entries }: RailProps) {
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 inset-y-4 left-4 z-10 flex w-14 flex-col items-center gap-1 p-2", "gl-surface gl-rail pointer-events-auto absolute inset-y-3 left-3 z-10 flex w-12 flex-col items-center gap-1 p-1",
"overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", "overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
)} )}
> >
@@ -95,8 +95,11 @@
/* --- surfaces ------------------------------------------------------------ */ /* --- surfaces ------------------------------------------------------------ */
/* No `position` of its own: this is worn by a tile, a rail and a notice alike,
and each of those is placed by whatever puts it there. Stating it here won
over the utility that placed them — an unlayered rule beats a layered one —
and collapsed the rail into the flow. */
.gl-surface { .gl-surface {
position: relative;
border: 1px solid var(--gl-border); border: 1px solid var(--gl-border);
border-radius: var(--gl-radius); border-radius: var(--gl-radius);
background: var(--gl-bg); background: var(--gl-bg);
@@ -181,7 +184,12 @@
opacity: 0; opacity: 0;
} }
.gl-pressable > :not(.gl-glow) { /* Lifted one at a time rather than by a blanket rule over every child: that
one restated `position` on things already placed — a sliding pill, a switch's
thumb — and dropped them back into the flow. */
.gl-switch-thumb,
.gl-segment-label,
.gl-lift {
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
@@ -316,7 +324,7 @@
} }
.gl-segment[data-active] { .gl-segment[data-active] {
color: var(--foreground); color: var(--primary-foreground);
} }
.gl-segment-thumb { .gl-segment-thumb {
@@ -324,13 +332,11 @@
inset: 0; inset: 0;
z-index: 0; z-index: 0;
border-radius: 9999px; border-radius: 9999px;
background: var(--gl-bg-strong); background: color-mix(in srgb, var(--primary) 85%, transparent);
box-shadow: var(--gl-inset); box-shadow: var(--gl-glow), var(--gl-inset);
} }
.gl-segment-label { .gl-segment-label {
position: relative;
z-index: 1;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -80,7 +80,7 @@ export function Button({
onClick={onClick} onClick={onClick}
> >
<Skin presses={presses} done={done} /> <Skin presses={presses} done={done} />
<span className="min-w-0 truncate">{children}</span> <span className="m3-lift min-w-0 truncate">{children}</span>
</button> </button>
) )
} }
@@ -207,11 +207,14 @@ export function Select({
disabled={disabled} disabled={disabled}
className="m3-field m3-pressable justify-between" className="m3-field m3-pressable justify-between"
> >
<span className="min-w-0 truncate"> <span className="m3-lift min-w-0 truncate">
<SelectPrimitive.Value placeholder="Choose" /> <SelectPrimitive.Value placeholder="Choose" />
</span> </span>
<SelectPrimitive.Icon asChild> <SelectPrimitive.Icon asChild>
<ChevronDown className="size-4 shrink-0 opacity-60" aria-hidden /> <ChevronDown
className="m3-lift size-4 shrink-0 opacity-60"
aria-hidden
/>
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
<SelectPrimitive.Portal> <SelectPrimitive.Portal>
@@ -58,6 +58,7 @@ export function Frame({
title, title,
issue, issue,
grip, grip,
scroll,
selected, selected,
onClick, onClick,
children, children,
@@ -85,7 +86,9 @@ export function Frame({
{issue ? <Issue issue={issue} /> : null} {issue ? <Issue issue={issue} /> : null}
</div> </div>
) : null} ) : null}
<div className="dui-frame-body">{children}</div> <div className={cn("dui-frame-body", scroll && "dui-frame-scroll")}>
{children}
</div>
{/* No header to sit in, so the fault takes the corner instead — a widget {/* No header to sit in, so the fault takes the corner instead — a widget
drawn without its title still says when it is mis-wired. */} drawn without its title still says when it is mis-wired. */}
{!title && issue ? ( {!title && issue ? (
@@ -121,7 +124,7 @@ export function Rail({ entries }: RailProps) {
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 inset-y-4 left-4 z-10 flex w-14 flex-col items-center gap-1 p-2", "m3-rail pointer-events-auto absolute inset-y-3 left-3 z-10 flex w-12 flex-col items-center gap-1 p-1",
// More dashboards than the column is tall still scroll, but no bar is // More dashboards than the column is tall still scroll, but no bar is
// ever drawn: a wall panel is swiped, and there is no room for one. // ever drawn: a wall panel is swiped, and there is no room for one.
"overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", "overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
@@ -100,7 +100,12 @@
background: currentColor; background: currentColor;
} }
.m3-pressable > :not(.m3-state):not(.m3-ripple) { /* Lifted one at a time rather than by a blanket rule over every child: that
one restated `position` on things already placed — a sliding pill, a switch's
thumb — and dropped them back into the flow. */
.m3-switch-thumb,
.m3-segment-label,
.m3-lift {
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
@@ -233,7 +238,7 @@
} }
.m3-segment[data-active] { .m3-segment[data-active] {
color: var(--card-foreground); color: var(--primary-foreground);
} }
.m3-segment-thumb { .m3-segment-thumb {
@@ -241,12 +246,10 @@
inset: 0; inset: 0;
z-index: 0; z-index: 0;
border-radius: 9999px; border-radius: 9999px;
background: var(--m3-sc); background: var(--primary);
} }
.m3-segment-label { .m3-segment-label {
position: relative;
z-index: 1;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -76,7 +76,7 @@ export const WIDGET_LABELS: Record<WidgetKind, string> = {
switch: "Switch", switch: "Switch",
slider: "Slider", slider: "Slider",
input: "Input", input: "Input",
dropdown: "Dropdown", dropdown: "Selector",
color: "Colour", color: "Colour",
} }
+21 -17
View File
@@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"
import { createFileRoute, redirect } from "@tanstack/react-router" import { createFileRoute, redirect } from "@tanstack/react-router"
import type { Dashboard } from "@/components/Dashboard/DashboardView" import type { Dashboard } from "@/components/Dashboard/DashboardView"
import { PanelRail, RAIL_INSET } from "@/components/Dashboard/PanelRail" import { PanelRail } from "@/components/Dashboard/PanelRail"
import { PanelSurface } from "@/components/Dashboard/PanelSurface" import { PanelSurface } from "@/components/Dashboard/PanelSurface"
import { import {
dashboardQueryOptions, dashboardQueryOptions,
@@ -85,25 +85,29 @@ function PanelRoute() {
root.className, root.className,
stacked ? "overflow-y-auto" : "overflow-hidden", stacked ? "overflow-y-auto" : "overflow-hidden",
)} )}
style={{ style={root.style}
...root.style,
...(rail ? { paddingLeft: RAIL_INSET } : {}),
}}
> >
<LookProvider dashboard={dashboard as Dashboard | undefined}> <LookProvider dashboard={dashboard as Dashboard | undefined}>
{rail && current ? (
<PanelRail
dashboards={dashboards}
current={current}
linkFor={(name) => ({
to: "/panel/$id",
params: { id },
search: { d: name },
})}
/>
) : null}
{dashboard ? ( {dashboard ? (
<PanelSurface dashboard={dashboard as Dashboard} stacked={stacked} /> <PanelSurface
dashboard={dashboard as Dashboard}
stacked={stacked}
// Drawn on the panel rather than beside it: a screen showing four
// dashboards shows the way between them too.
rail={
rail && current ? (
<PanelRail
dashboards={dashboards}
current={current}
linkFor={(name) => ({
to: "/panel/$id",
params: { id },
search: { d: name },
})}
/>
) : null
}
/>
) : null} ) : null}
</LookProvider> </LookProvider>
</main> </main>
+132
View File
@@ -0,0 +1,132 @@
import { expect, test } from "@playwright/test"
import { api, apiPage, deleteAll } from "./utils/api"
/**
* A panel carrying more than one dashboard draws the way between them.
*
* The rail is part of what the screen shows rather than chrome beside it: it
* is inside the canvas, scaled with it, and wearing the dashboard's own look.
* Drawn outside, it was a strip of the app's design bolted to the edge of
* somebody's wall panel — and on a scaled canvas it did not even line up.
*/
const flowName = `test_panel_${Date.now().toString(36)}`
const first = `${flowName}_a`
const second = `${flowName}_b`
test.use({ storageState: "playwright/.auth/user.json" })
test.describe.configure({ mode: "serial" })
test.beforeAll(async ({ browser }) => {
const page = await apiPage(browser)
await api(page, `/flows/${flowName}`, {
method: "PUT",
data: {
name: flowName,
title: "Panel",
version: 1,
nodes: [
{
id: "emit",
type: "python",
provides: [{ name: "level", dtype: "float" }],
},
],
},
})
const flow = await (await api(page, `/flows/${flowName}?draft=true`)).json()
await api(page, `/flows/${flowName}/publish`, {
method: "POST",
data: { version: flow.definition.version },
})
await api(page, `/messages/${flowName}.level`, {
method: "POST",
data: { value: 4 },
})
for (const name of [first, second]) {
await api(page, `/dashboards/${name}`, { method: "POST" })
const doc = await (await api(page, `/dashboards/${name}?draft=true`)).json()
doc.settings = { look: { value: "glass" } }
doc.pages[0].sections[0].widgets = [
{
id: "stat",
type: "stat",
title: "Level",
layout: { lg: { x: 0, y: 0, w: 3, h: 2 } },
config: { message: `${flowName}.level`, dtype: "float" },
},
]
const put = await (
await api(page, `/dashboards/${name}`, { method: "PUT", data: doc })
).json()
await api(page, `/dashboards/${name}/publish`, {
method: "POST",
data: { version: put.version },
})
}
await api(page, "/panels/", {
method: "PUT",
data: {
panels: [{ id: flowName, title: "Hall", dashboards: [first, second] }],
},
})
await page.close()
})
test.afterAll(async ({ browser }) => {
await deleteAll(browser, [
`/dashboards/${first}`,
`/dashboards/${second}`,
`/flows/${flowName}`,
])
})
test("the rail is drawn on the panel, in the panel's own look", async ({
page,
}) => {
await page.goto(`/panel/${flowName}?d=${first}`)
const rail = page.getByTestId("panel-rail")
await rail.waitFor({ timeout: 20000 })
const canvas = (await page.getByTestId("canvas-surface").boundingBox())!
const box = (await rail.boundingBox())!
expect(box.x, "the rail starts left of the panel").toBeGreaterThanOrEqual(
canvas.x - 1,
)
expect(box.y, "the rail starts above the panel").toBeGreaterThanOrEqual(
canvas.y - 1,
)
expect(
box.x + box.width,
"the rail runs off the right of the panel",
).toBeLessThanOrEqual(canvas.x + canvas.width + 1)
expect(
box.y + box.height,
"the rail runs off the bottom of the panel",
).toBeLessThanOrEqual(canvas.y + canvas.height + 1)
// Inside the canvas is also what makes it wear the look: the attributes and
// the palette are stated on the canvas, and inheritance does the rest.
const inside = await rail.evaluate(
(el) => el.closest("[data-testid=canvas-surface]") !== null,
)
expect(inside, "the rail is drawn outside the panel it belongs to").toBe(true)
// And the arrangement keeps clear of it rather than sitting under it.
const tile = (await page.getByTestId("widget-frame").first().boundingBox())!
expect(tile.x, "a widget is drawn under the rail").toBeGreaterThanOrEqual(
box.x + box.width,
)
})
test("the rail switches the panel between its dashboards", async ({ page }) => {
await page.goto(`/panel/${flowName}?d=${first}`)
await page.getByTestId(`panel-rail-${second}`).click()
await expect(page).toHaveURL(new RegExp(`d=${second}`))
await expect(page.getByTestId(`panel-rail-${second}`)).toHaveAttribute(
"aria-current",
"page",
)
})
+31
View File
@@ -374,6 +374,30 @@ test("a reading is written to its own precision while it is moving", async ({
await publish(page, w("setpoint"), 21.5) await publish(page, w("setpoint"), 21.5)
}) })
test("the arrangement is held off the panel's edges", async ({ page }) => {
await openPanel(page)
const canvas = (await page.getByTestId("canvas-surface").boundingBox())!
const tiles = await page.getByTestId("widget-frame").all()
const boxes = await Promise.all(tiles.map((tile) => tile.boundingBox()))
// A tile sits as far from the edge of the screen as it does from its
// neighbour. The scale is whatever fits the viewport, so the margin is
// asserted as "some room" rather than a pixel count.
for (const box of boxes) {
expect(box!.x, "a tile is flush against the left edge").toBeGreaterThan(
canvas.x + 1,
)
expect(box!.y, "a tile is flush against the top edge").toBeGreaterThan(
canvas.y + 1,
)
expect(
box!.x + box!.width,
"a tile is flush against the right edge",
).toBeLessThan(canvas.x + canvas.width - 1)
}
})
test("a widget is drawn inside its tile rather than scrolled", async ({ test("a widget is drawn inside its tile rather than scrolled", async ({
page, page,
}) => { }) => {
@@ -647,6 +671,13 @@ for (const scheme of ["light", "dark"] as const) {
await expect(surface).toHaveAttribute("data-palette", "") await expect(surface).toHaveAttribute("data-palette", "")
await expect(page.getByTestId("canvas-ground")).toBeVisible() await expect(page.getByTestId("canvas-ground")).toBeVisible()
// The margin holds the arrangement off the edges; it does not hold the
// ground off with it, because a background covers the whole panel.
const canvas = (await surface.boundingBox())!
const ground = (await page.getByTestId("canvas-ground").boundingBox())!
expect(Math.round(ground.width)).toBe(Math.round(canvas.width))
expect(Math.round(ground.height)).toBe(Math.round(canvas.height))
const primary = await page const primary = await page
.getByTestId("bar-fill") .getByTestId("bar-fill")
.first() .first()