diff --git a/NOTEPAD.md b/NOTEPAD.md index 642825e..502ce83 100644 --- a/NOTEPAD.md +++ b/NOTEPAD.md @@ -12,8 +12,11 @@ Deferring because out of scope is fine, but don't mention deferring than. ### 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: 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 +- 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 +- 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 - 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 @@ -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 dashboard no longer shares them — it has its own, one per look — so 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 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 @@ -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 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 +- 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 -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 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 -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. - 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. @@ -243,26 +236,11 @@ again. ### 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 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 the encoders now, so fixing one is a decision about one fixture rather than a 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 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, @@ -280,10 +258,7 @@ does not settle. nodes speak to the house broker here. Either a shared client registry or a 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 @@ -307,32 +282,7 @@ does not settle. - 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. -### Wall-panel parity with the current home dashboard - -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. +### Wall-panel - 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 @@ -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 looks the same as one a run passes in. -Deliberately not ported: the local-state/timestamp reconciliation the old -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 +### Dashboard - 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 diff --git a/docs/interface/dashboards.md b/docs/interface/dashboards.md index 18bceaa..8a824d4 100644 --- a/docs/interface/dashboards.md +++ b/docs/interface/dashboards.md @@ -49,7 +49,7 @@ screen reader calls its controls, and what a published value is labelled with. | **Switch** | `bool` | on/off | | **Slider** | `float`, `int` | min, max, step | | **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 | The colour wheel sends `[h, s, v]` by default — hue 0-360 degrees, saturation diff --git a/frontend/src/components/Dashboard/DashboardEditor.tsx b/frontend/src/components/Dashboard/DashboardEditor.tsx index 27635a3..9e67009 100644 --- a/frontend/src/components/Dashboard/DashboardEditor.tsx +++ b/frontend/src/components/Dashboard/DashboardEditor.tsx @@ -54,8 +54,8 @@ import { appPath } from "@/lib/portal" import { cn } from "@/lib/utils" import { handleError } from "@/utils" import { + areaOf, CanvasSurface, - canvasOf, columnsOf, type Dashboard, DashboardView, @@ -69,7 +69,7 @@ import { sectionsOf, widgetsOf, } from "./DashboardView" -import { PanelRail, RAIL_INSET } from "./PanelRail" +import { PanelRail } from "./PanelRail" import { DashboardPanel, WidgetPanel } from "./panels" import { dashboardKeys, @@ -327,7 +327,6 @@ export function DashboardEditor({ // 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. const widgets = page ? flatWidgets(page) : [] - const canvas = canvasOf(draft) const updateWidgets = (next: WidgetDef[]) => { 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 rows = rowsOf(draft) + const rows = rowsOf(draft, hasRail) + // maxRows below only constrains a *drag*. A stored placement is corrected // against `cols` and nothing else, so a canvas that shrank leaves whatever // 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 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) => { setSelected(null) setSettingsOpen(false) @@ -492,19 +496,37 @@ export function DashboardEditor({ /> ) : ( - + ({ + to: "/dashboards/$name", + params: { name }, + search: edit ? { edit: true } : {}, + })} + /> + ) : null + } + > {(scale) => !edit ? ( - + ) : ( - {railDashboards && !stacked ? ( - ({ - to: "/dashboards/$name", - params: { name }, - search: edit ? { edit: true } : {}, - })} - /> - ) : null} -
{body} @@ -566,7 +571,6 @@ export function DashboardEditor({ "pointer-events-none absolute inset-0 transition-[right] duration-200", panelOpen && "md:right-[27rem]", )} - style={railDashboards && !stacked ? { left: RAIL_INSET } : undefined} > diff --git a/frontend/src/components/Dashboard/DashboardView.tsx b/frontend/src/components/Dashboard/DashboardView.tsx index 0233e10..36c85bd 100644 --- a/frontend/src/components/Dashboard/DashboardView.tsx +++ b/frontend/src/components/Dashboard/DashboardView.tsx @@ -16,7 +16,7 @@ import { useDashboardBackground, } from "./settings" 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 { gridStagger, LOOK } from "./ui/core/motion" 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. */ 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 * 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 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. */ -export const rowHeightOf = (dashboard: Dashboard) => - Math.round(columnWidthOf(dashboard) / CELL_ASPECT) +export const rowHeightOf = (dashboard: Dashboard, rail = false) => + Math.round(columnWidthOf(dashboard, rail) / CELL_ASPECT) /** Distance between two row origins — what a widget's `y` is counted in. */ -export const rowPitchOf = (dashboard: Dashboard) => - rowHeightOf(dashboard) + GRID_GAP +export const rowPitchOf = (dashboard: Dashboard, rail = false) => + rowHeightOf(dashboard, rail) + GRID_GAP /** 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( 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({ dashboard, dots, + rail, children, }: { dashboard: Dashboard /** Draw the placement grid. A wall panel is not being arranged, so: no. */ dots?: boolean + /** The dashboard-switching rail, drawn on the panel rather than beside it. */ + rail?: React.ReactNode children: (scale: number) => React.ReactNode }) { const ref = useRef(null) @@ -140,6 +184,7 @@ export function CanvasSurface({ const { width, height } = canvasOf(dashboard) const scale = Math.min(box.width / width, box.height / height) const root = useCanvasRoot(dashboard) + const area = areaOf(dashboard, Boolean(rail)) return (
@@ -165,20 +210,33 @@ export function CanvasSurface({ top: (box.height - height * scale) / 2, transform: `scale(${scale})`, transformOrigin: "top left", - // One dot per cell corner. The column pitch the grid snaps to - // is (width + gap) / columns; a row is one row plus the gap. - "--dot-x": `${(width + GRID_GAP) / columnsOf(dashboard)}px`, - "--dot-y": `${rowPitchOf(dashboard)}px`, + // One dot per cell corner, counted across the arranged area + // rather than the whole panel: the margin is not a cell. + "--dot-x": `${(area.width + GRID_GAP) / columnsOf(dashboard)}px`, + "--dot-y": `${rowPitchOf(dashboard, Boolean(rail))}px`, } as React.CSSProperties } > + {/* Full bleed, and under everything: a background covers the + panel, margin included. */} - {/* Over the ground rather than under it: the dots are what is - being arranged against, and a wallpaper must not hide them. */} - {dots ? ( -
- ) : null} - {children(scale)} + {rail} +
+ {/* Over the ground rather than under it: the dots are what is + being arranged against, and a wallpaper must not hide them. */} + {dots ? ( +
+ ) : null} + {children(scale)} +
) : null} @@ -293,11 +351,14 @@ export function DashboardView({ dashboard, renderWidget, stacked, + rail, }: { dashboard: Dashboard renderWidget?: (widget: WidgetDef) => React.ReactNode /** One column at the viewport's width, for a phone. */ 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 // — that is what the rail is for — so nothing writes a second page. @@ -343,6 +404,7 @@ export function DashboardView({ widgets={widgets} columns={columns} stacked={stacked} + rail={rail} renderWidget={renderWidget} /> @@ -363,12 +425,14 @@ function WidgetGrid({ widgets, columns, stacked, + rail, renderWidget, }: { dashboard: Dashboard widgets: WidgetDef[] columns: number stacked?: boolean + rail?: boolean renderWidget?: (widget: WidgetDef) => React.ReactNode }) { const { Frame } = useUi() @@ -385,7 +449,7 @@ function WidgetGrid({ "--widget-cols": columns, // The row height follows the canvas, so the CSS grid and the // editor's grid library cannot drift apart. - "--row-height": `${rowHeightOf(dashboard)}px`, + "--row-height": `${rowHeightOf(dashboard, rail)}px`, } as React.CSSProperties } > @@ -403,6 +467,7 @@ function WidgetGrid({ diff --git a/frontend/src/components/Dashboard/PanelRail.tsx b/frontend/src/components/Dashboard/PanelRail.tsx index 6503dcc..6de8d88 100644 --- a/frontend/src/components/Dashboard/PanelRail.tsx +++ b/frontend/src/components/Dashboard/PanelRail.tsx @@ -4,10 +4,6 @@ import type { LinkProps } from "@tanstack/react-router" import { dashboardQueryOptions } from "@/components/Dashboard/queries" 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. * diff --git a/frontend/src/components/Dashboard/PanelSurface.tsx b/frontend/src/components/Dashboard/PanelSurface.tsx index ac8853e..c4313b7 100644 --- a/frontend/src/components/Dashboard/PanelSurface.tsx +++ b/frontend/src/components/Dashboard/PanelSurface.tsx @@ -21,11 +21,14 @@ import { LookProvider } from "@/components/Dashboard/ui/core/look" export function PanelSurface({ dashboard, stacked, + rail, }: { dashboard: Dashboard /** A landscape arrangement scaled onto a phone comes out at about a fifth of * its size, which reads as nothing at all. Stack it instead. */ stacked?: boolean + /** The way between this panel's dashboards, drawn on the canvas itself. */ + rail?: React.ReactNode }) { return (
@@ -34,8 +37,8 @@ export function PanelSurface({ ) : ( // The panel's own surface, scaled to fit. No dots: nothing is being // arranged here. - - {() => } + + {() => } )} {/* Outside the canvas, so it needs the look stated for it. */} diff --git a/frontend/src/components/Dashboard/ui/core/config.ts b/frontend/src/components/Dashboard/ui/core/config.ts index 1642e03..202ec24 100644 --- a/frontend/src/components/Dashboard/ui/core/config.ts +++ b/frontend/src/components/Dashboard/ui/core/config.ts @@ -67,6 +67,19 @@ export const fractionOf = ( export const showTitle = (widget: WidgetDef): boolean => 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. */ export function asOriginal( selected: string, diff --git a/frontend/src/components/Dashboard/ui/core/contract.ts b/frontend/src/components/Dashboard/ui/core/contract.ts index cc9a594..f686d1a 100644 --- a/frontend/src/components/Dashboard/ui/core/contract.ts +++ b/frontend/src/components/Dashboard/ui/core/contract.ts @@ -36,6 +36,8 @@ export type FrameProps = { issue?: string | null /** Make this widget draggable in the editor. */ grip?: boolean + /** Let the body scroll. Only what is written or listed asks for it. */ + scroll?: boolean selected?: boolean onClick?: React.MouseEventHandler children: React.ReactNode diff --git a/frontend/src/components/Dashboard/ui/glass/Controls.tsx b/frontend/src/components/Dashboard/ui/glass/Controls.tsx index de3a38e..7a37e14 100644 --- a/frontend/src/components/Dashboard/ui/glass/Controls.tsx +++ b/frontend/src/components/Dashboard/ui/glass/Controls.tsx @@ -51,7 +51,7 @@ export function Button({ onClick={onClick} > - {children} + {children} ) } @@ -181,11 +181,14 @@ export function Select({ className="gl-field gl-pressable justify-between" > - + - + diff --git a/frontend/src/components/Dashboard/ui/glass/Surfaces.tsx b/frontend/src/components/Dashboard/ui/glass/Surfaces.tsx index 56f659b..9dff9ed 100644 --- a/frontend/src/components/Dashboard/ui/glass/Surfaces.tsx +++ b/frontend/src/components/Dashboard/ui/glass/Surfaces.tsx @@ -94,6 +94,7 @@ export function Frame({ title, issue, grip, + scroll, selected, onClick, children, @@ -121,7 +122,9 @@ export function Frame({ {issue ? : null}
) : null} -
{children}
+
+ {children} +
{/* No header to sit in, so the fault takes the corner instead. */} {!title && issue ? (
@@ -155,7 +158,7 @@ export function Rail({ entries }: RailProps) { aria-label="Dashboards on this panel" data-testid={TESTID.rail} 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", )} > diff --git a/frontend/src/components/Dashboard/ui/glass/glass.css b/frontend/src/components/Dashboard/ui/glass/glass.css index 56038dd..f838bbd 100644 --- a/frontend/src/components/Dashboard/ui/glass/glass.css +++ b/frontend/src/components/Dashboard/ui/glass/glass.css @@ -95,8 +95,11 @@ /* --- 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 { - position: relative; border: 1px solid var(--gl-border); border-radius: var(--gl-radius); background: var(--gl-bg); @@ -181,7 +184,12 @@ 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; z-index: 1; } @@ -316,7 +324,7 @@ } .gl-segment[data-active] { - color: var(--foreground); + color: var(--primary-foreground); } .gl-segment-thumb { @@ -324,13 +332,11 @@ inset: 0; z-index: 0; border-radius: 9999px; - background: var(--gl-bg-strong); - box-shadow: var(--gl-inset); + background: color-mix(in srgb, var(--primary) 85%, transparent); + box-shadow: var(--gl-glow), var(--gl-inset); } .gl-segment-label { - position: relative; - z-index: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/frontend/src/components/Dashboard/ui/material/Controls.tsx b/frontend/src/components/Dashboard/ui/material/Controls.tsx index 911ff26..206d2b5 100644 --- a/frontend/src/components/Dashboard/ui/material/Controls.tsx +++ b/frontend/src/components/Dashboard/ui/material/Controls.tsx @@ -80,7 +80,7 @@ export function Button({ onClick={onClick} > - {children} + {children} ) } @@ -207,11 +207,14 @@ export function Select({ disabled={disabled} className="m3-field m3-pressable justify-between" > - + - + diff --git a/frontend/src/components/Dashboard/ui/material/Surfaces.tsx b/frontend/src/components/Dashboard/ui/material/Surfaces.tsx index 1cefaf3..b8a585a 100644 --- a/frontend/src/components/Dashboard/ui/material/Surfaces.tsx +++ b/frontend/src/components/Dashboard/ui/material/Surfaces.tsx @@ -58,6 +58,7 @@ export function Frame({ title, issue, grip, + scroll, selected, onClick, children, @@ -85,7 +86,9 @@ export function Frame({ {issue ? : null}
) : null} -
{children}
+
+ {children} +
{/* 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. */} {!title && issue ? ( @@ -121,7 +124,7 @@ export function Rail({ entries }: RailProps) { aria-label="Dashboards on this panel" data-testid={TESTID.rail} 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 // ever drawn: a wall panel is swiped, and there is no room for one. "overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", diff --git a/frontend/src/components/Dashboard/ui/material/material.css b/frontend/src/components/Dashboard/ui/material/material.css index 53bd403..8f68b6f 100644 --- a/frontend/src/components/Dashboard/ui/material/material.css +++ b/frontend/src/components/Dashboard/ui/material/material.css @@ -100,7 +100,12 @@ 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; z-index: 1; } @@ -233,7 +238,7 @@ } .m3-segment[data-active] { - color: var(--card-foreground); + color: var(--primary-foreground); } .m3-segment-thumb { @@ -241,12 +246,10 @@ inset: 0; z-index: 0; border-radius: 9999px; - background: var(--m3-sc); + background: var(--primary); } .m3-segment-label { - position: relative; - z-index: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/frontend/src/components/Dashboard/widgets.tsx b/frontend/src/components/Dashboard/widgets.tsx index f9e1150..b0dc466 100644 --- a/frontend/src/components/Dashboard/widgets.tsx +++ b/frontend/src/components/Dashboard/widgets.tsx @@ -76,7 +76,7 @@ export const WIDGET_LABELS: Record = { switch: "Switch", slider: "Slider", input: "Input", - dropdown: "Dropdown", + dropdown: "Selector", color: "Colour", } diff --git a/frontend/src/routes/panel.$id.tsx b/frontend/src/routes/panel.$id.tsx index c2bfbf1..b942ef2 100644 --- a/frontend/src/routes/panel.$id.tsx +++ b/frontend/src/routes/panel.$id.tsx @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query" import { createFileRoute, redirect } from "@tanstack/react-router" 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 { dashboardQueryOptions, @@ -85,25 +85,29 @@ function PanelRoute() { root.className, stacked ? "overflow-y-auto" : "overflow-hidden", )} - style={{ - ...root.style, - ...(rail ? { paddingLeft: RAIL_INSET } : {}), - }} + style={root.style} > - {rail && current ? ( - ({ - to: "/panel/$id", - params: { id }, - search: { d: name }, - })} - /> - ) : null} {dashboard ? ( - + ({ + to: "/panel/$id", + params: { id }, + search: { d: name }, + })} + /> + ) : null + } + /> ) : null} diff --git a/frontend/tests/panel.spec.ts b/frontend/tests/panel.spec.ts new file mode 100644 index 0000000..0f2b228 --- /dev/null +++ b/frontend/tests/panel.spec.ts @@ -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", + ) +}) diff --git a/frontend/tests/widgets.spec.ts b/frontend/tests/widgets.spec.ts index 8970717..abc8327 100644 --- a/frontend/tests/widgets.spec.ts +++ b/frontend/tests/widgets.spec.ts @@ -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) }) +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 ({ page, }) => { @@ -647,6 +671,13 @@ for (const scheme of ["light", "dark"] as const) { await expect(surface).toHaveAttribute("data-palette", "") 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 .getByTestId("bar-fill") .first()