From 0b5ce4fcbbc13d777a1c6eb195164f3e0e174ae9 Mon Sep 17 00:00:00 2001 From: stroblme Date: Sun, 23 Aug 2026 21:52:14 +0200 Subject: [PATCH] Rework the dashboard into two looks over one behaviour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dashboard is a wall panel somebody hangs in their own hallway, so it now wears what they choose: a look, and a palette of their own colours. Two complete component sets live under `Dashboard/ui/` — `glass` (translucent panes over a slowly moving ground) and `material` (Material 3 tonal cards) — behind one prop contract. Every control's state, keyboard and `aria-` live in `ui/core` and are shared, so the two sets are the same dashboard drawn twice rather than two products: a set only decides what a control looks like while doing it. Four settings join the channel, each drivable by a flow like any other: `look`, `palette`, `background` and `touch`. A palette is an ordered list of hex colours — background, surface, primary, accent, text, then more chart colours — pasted from a coolors.co link or typed, written onto the canvas as the token variables everything already reads. Trailing roles are derived, so three colours are a whole dashboard, and derived text is held to AA rather than trusted (`theme.check.ts` measures it). A palette also decides light or dark, since its first colour is the ground. Widgets are measured against their own tile with container queries rather than against the viewport, animate through `motion`, and can be drawn without their title. The three reworks: - a bar draws a row per reading, up to eight, each in the dashboard's own data colours and each able to carry its own scale — replacing readings nested in one fill, which could only ever share one colour and stop at three. Documents written the old way are read as rows. - a chart's range picker moved to a column down its right-hand edge, which gives the plot back a whole row of a short tile. - the colour wheel became a disc: hue is the angle and saturation the distance from the middle, so a colour is one gesture rather than three, with brightness on a slider beside it. `index.css` and `lib/motion.ts` are untouched — the dashboard overrides token *values* on its canvas, never the blocks the two repos share. --- DESIGN.md | 8 + NOTEPAD.md | 59 +- backend/fluksio/flow/dashboards.py | 80 ++- backend/tests/flow/test_dashboards.py | 31 + docs/interface/dashboards.md | 72 ++- frontend/src/components/Common/UplotChart.tsx | 62 +- .../src/components/Dashboard/BarWidget.tsx | 197 +----- .../src/components/Dashboard/ChartWidget.tsx | 77 ++- .../src/components/Dashboard/ClockWidget.tsx | 4 +- .../src/components/Dashboard/ColorWidget.tsx | 353 ++--------- .../components/Dashboard/DashboardEditor.tsx | 84 ++- .../components/Dashboard/DashboardView.tsx | 199 +++++-- .../components/Dashboard/ForecastWidget.tsx | 14 +- .../src/components/Dashboard/IconWidget.tsx | 14 +- .../src/components/Dashboard/PanelRail.tsx | 77 +-- .../src/components/Dashboard/PanelSurface.tsx | 23 +- .../src/components/Dashboard/color.check.ts | 47 +- .../src/components/Dashboard/dashboard.css | 89 +-- .../src/components/Dashboard/palette.check.ts | 42 +- frontend/src/components/Dashboard/panels.tsx | 425 ++++++++++--- frontend/src/components/Dashboard/publish.tsx | 14 +- .../src/components/Dashboard/settings.tsx | 73 ++- .../src/components/Dashboard/ui/core/arc.ts | 26 + .../src/components/Dashboard/ui/core/color.ts | 141 +++++ .../Dashboard/ui/core/config.check.ts | 96 +++ .../components/Dashboard/ui/core/config.ts | 113 ++++ .../components/Dashboard/ui/core/contract.ts | 170 ++++++ .../components/Dashboard/ui/core/controls.ts | 222 +++++++ .../src/components/Dashboard/ui/core/core.css | 278 +++++++++ .../src/components/Dashboard/ui/core/disc.ts | 136 +++++ .../src/components/Dashboard/ui/core/look.tsx | 92 +++ .../components/Dashboard/ui/core/motion.ts | 53 ++ .../Dashboard/ui/core/theme.check.ts | 185 ++++++ .../src/components/Dashboard/ui/core/theme.ts | 233 ++++++++ .../components/Dashboard/ui/core/values.ts | 105 ++++ .../Dashboard/ui/glass/Controls.tsx | 234 ++++++++ .../components/Dashboard/ui/glass/Data.tsx | 217 +++++++ .../Dashboard/ui/glass/Surfaces.tsx | 207 +++++++ .../components/Dashboard/ui/glass/glass.css | 472 +++++++++++++++ .../components/Dashboard/ui/glass/index.ts | 29 + frontend/src/components/Dashboard/ui/index.ts | 25 + .../Dashboard/ui/material/Controls.tsx | 263 ++++++++ .../components/Dashboard/ui/material/Data.tsx | 211 +++++++ .../Dashboard/ui/material/Surfaces.tsx | 183 ++++++ .../components/Dashboard/ui/material/index.ts | 29 + .../Dashboard/ui/material/material.css | 379 ++++++++++++ frontend/src/components/Dashboard/widgets.tsx | 560 ++++-------------- frontend/src/components/Flow/liveStore.ts | 41 +- frontend/src/routes/panel.$id.tsx | 55 +- frontend/src/routes/view.$name.tsx | 14 +- frontend/tests/editor.spec.ts | 59 +- frontend/tests/widgets.spec.ts | 213 ++++--- 52 files changed, 5517 insertions(+), 1568 deletions(-) create mode 100644 frontend/src/components/Dashboard/ui/core/arc.ts create mode 100644 frontend/src/components/Dashboard/ui/core/color.ts create mode 100644 frontend/src/components/Dashboard/ui/core/config.check.ts create mode 100644 frontend/src/components/Dashboard/ui/core/config.ts create mode 100644 frontend/src/components/Dashboard/ui/core/contract.ts create mode 100644 frontend/src/components/Dashboard/ui/core/controls.ts create mode 100644 frontend/src/components/Dashboard/ui/core/core.css create mode 100644 frontend/src/components/Dashboard/ui/core/disc.ts create mode 100644 frontend/src/components/Dashboard/ui/core/look.tsx create mode 100644 frontend/src/components/Dashboard/ui/core/motion.ts create mode 100644 frontend/src/components/Dashboard/ui/core/theme.check.ts create mode 100644 frontend/src/components/Dashboard/ui/core/theme.ts create mode 100644 frontend/src/components/Dashboard/ui/core/values.ts create mode 100644 frontend/src/components/Dashboard/ui/glass/Controls.tsx create mode 100644 frontend/src/components/Dashboard/ui/glass/Data.tsx create mode 100644 frontend/src/components/Dashboard/ui/glass/Surfaces.tsx create mode 100644 frontend/src/components/Dashboard/ui/glass/glass.css create mode 100644 frontend/src/components/Dashboard/ui/glass/index.ts create mode 100644 frontend/src/components/Dashboard/ui/index.ts create mode 100644 frontend/src/components/Dashboard/ui/material/Controls.tsx create mode 100644 frontend/src/components/Dashboard/ui/material/Data.tsx create mode 100644 frontend/src/components/Dashboard/ui/material/Surfaces.tsx create mode 100644 frontend/src/components/Dashboard/ui/material/index.ts create mode 100644 frontend/src/components/Dashboard/ui/material/material.css diff --git a/DESIGN.md b/DESIGN.md index 8d3219f..19f4551 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -35,3 +35,11 @@ One breakpoint, `md` (768px), and a phone inspects rather than arranges: no dragging, no placing, no resizing. The rules that keep it that way — and the "nothing scrolls horizontally" check that enforces them — are in DESIGN-GUIDELINES.md → Responsive. + +## Dashboards + +A user-defined dashboard deliberately steps outside the shared design system: +it has two complete component sets of its own (`glass` and `material`) and +takes its colours from the document rather than from the tokens. See +DESIGN-GUIDELINES.md → The dashboard is exempt. The shells, the flow editor and +every admin screen are unaffected. diff --git a/NOTEPAD.md b/NOTEPAD.md index df5ae1a..f2b5a84 100644 --- a/NOTEPAD.md +++ b/NOTEPAD.md @@ -10,46 +10,37 @@ Deferring because out of scope is fine, but don't mention deferring than. ## Open -### Dashboard UI rework (dedicated session) - -Rework the dashboard UI to make it more flexible in terms of the look and feel. -The dasboard is allowed to break out of the design guidelines. -I consider following design options a) overall appearance: material look or liquid glass (einUI https://github.com/einui/einui) b) color palette (https://colorhunt.co). -Based on a) and b) it should be possible to derive almost endles combinations of looks. -The color palette should affect affect the overall theming of all widgets -The EinUI components should be used as a reference for the different components needed, the general layout/style of components and essentially the foundation for the liquid glass option. -Based on this, a set of material ui components should be derived, following https://mui.com/ . -Both variants of the components should be completely custom designed and fully owned (no new dependency). -The apperance should be strictly separated from the functionality (which both sets of components should share; i.e. choosing one or the other component set should not change the features of the dashboard). -All widgets (and the sidebar rail) should follow the appearance and should be reworked to receive animations through the motion library. -It should be possible to set a background in the dashboard (input, controllable externally via a node. In v1 this can be just an url to an image). -It should be possible to disable the title of a widget. -The dashboard setting should contain a "Touch" toggle, which, when activated, makes all widgets more touch friendly. -All components should react responsive to the size of the widget. -Components and animations should be carefully designed and adhere to high-quality design standards and taste. -A minimal research on dashboard/ component design should be conducted. - -Specific changes -- the nested bar should be changed in a multi-row bar with N inputs (and therefore n bars), color separated as the chart widget -- the range picker should go to the right (vertical) of the chart widget to allow for more vertical space of the chart -- the color picker should be changed to a circular color picker (disk with colors, saturation changes towards the center) and a vertical slider for the brightness - ### 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 - BUG/UI sync the theme state between panels and installations -- BUG/UI some dashboard widgets (like the color picker) are scrollable; we should make sure that no widgets (except for text widgets or list-related widgets) are scrollable +- 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 + shell is an ancestor. The dashboard's own components no longer use those + utilities — both looks read the tokens and their own `[data-look]` rules — so + what is left is the shadcn pieces still drawn inside a tile (tooltip, + skeleton). A real fix is `.theme-light` / `.theme-dark` namespacing, which is + a change to both repos' byte-identical token blocks. +- CHORE/UI: a stacked dashboard on a phone gets no ground — no blobs, no + background image, just the palette's own colour. The canvas is what carries + it, and a stacked panel is not drawn on one. +- CHORE/UI: the Glass look's ground is three compositor-animated gradients. If + a low-end wall panel stutters, drop one or set `LOOK.glass.drift` to 0. +- CHORE/UI: the app still has three copies of the segmented pill + (`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: the house panels are laid out for 1280x800 — twelve columns, twelve - rows. A chart's fixed chrome is now its title line and legend: the range picker - moved up onto the title and gave back its row, so the budget is nearer forty - pixels than eighty and a third chart may now fit — worth measuring against the - panel before adding one. The temperature history was the one dropped; - `history.climate_*` still answers, so it is a tile away. -- FEAT/UI: a bar's nested reading is captioned by its port name, which is - chosen for the graph rather than for somebody reading it across a room. - `Segment` now takes an optional `label`; the house dashboard sets one - (`Solar`), and it shows on the next frontend build. + 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 + may now fit — worth measuring against the panel before adding one. The + temperature history was the one dropped; `history.climate_*` still answers, + so it is a tile away. +- FEAT/UI: a bar row is captioned by its port name, which is chosen for the + graph rather than for somebody reading it across a room. A row takes an + optional `label`; the house dashboard sets one, and it shows on the next + frontend build. - BUG/NODE: **an MQTT client can hang forever on the way out.** `MqttNode` builds `aiomqtt.Client` without a `timeout`, so `Client.__aexit__` waits for the broker's diff --git a/backend/fluksio/flow/dashboards.py b/backend/fluksio/flow/dashboards.py index e85f725..5017708 100644 --- a/backend/fluksio/flow/dashboards.py +++ b/backend/fluksio/flow/dashboards.py @@ -32,11 +32,15 @@ DASHBOARD_DIR = "_dashboards" #: A chart cannot ask for an unbounded series; this is the ceiling. HISTORY_CAP = 5000 -#: How many readings a bar may nest inside its own. The limit is contrast, not -#: layout: the segments share one fill token, because no slot of the chart ramp -#: clears 3:1 against the outer one, and a fourth could not be told from its -#: neighbour. Mirrored in the client (``BarWidget.tsx``). -BAR_SEGMENTS = 3 +#: How many readings one bar draws. Mirrored in the client +#: (``ui/core/config.ts``, ``MAX_ROWS``). +#: +#: It used to be three, and the limit was contrast: the readings were nested +#: inside one fill and shared a single token, because no slot of the chart ramp +#: cleared 3:1 against the outer one. A bar now draws a row per reading in the +#: dashboard's own data colours, so what bounds it is how many tracks stay +#: legible stacked in one tile. +BAR_ROWS = 8 #: Resolved out here on purpose: the store has a ``list`` method, which #: shadows the builtin for any annotation written inside the class. @@ -103,10 +107,25 @@ WIDGET_DTYPES: dict[str, set[str]] = { #: (``frontend/src/components/Dashboard/settings.tsx``). SETTING_DTYPES: dict[str, str] = { # "system" | "light" | "dark". A panel in a room has no way to set the - # device preference the app otherwise inherits. + # device preference the app otherwise inherits. Idle while a palette is + # set: that names the ground, so which way it reads is already decided. "theme": "str", # Read-only: the input widgets stop publishing. "locked": "bool", + # "glass" | "material": which of the two component sets draws this + # dashboard. Anything else is material. The two share every feature; only + # the drawing differs. + "look": "str", + # The dashboard's own colours, as an ordered list of hex strings. Position + # is the role: background, surface, primary, accent, text, and anything + # after that is another colour for a chart. Trailing roles may be left off + # and are derived from what is there. + "palette": "list", + # An image drawn under the widgets, by URL. A flow publishing to it is what + # a wallpaper that changes looks like here. + "background": "str", + # Bigger controls and no hover states, for a panel that is touched. + "touch": "bool", } @@ -189,21 +208,39 @@ class WidgetDef(BaseModel): @property def inner_bindings(self) -> Bindings: - """A bar's nested readings, in either shape a document may carry them. + """A bar's nested readings, as documents written before rows carry them. One binding beside ``inner_dtype``, as a bar was written before it - stacked, or an ordered list of ``{message, dtype}`` — so an older - dashboard keeps drawing without being migrated first. + stacked, or an ordered list of ``{message, dtype}``. """ inner = self.config.get("inner") if isinstance(inner, list): - return [s for s in inner[:BAR_SEGMENTS] if isinstance(s, dict)] + return [s for s in inner if isinstance(s, dict)] dtype = self.config.get("inner_dtype") # A recorded type with nothing bound is still a type to be held to. if inner or dtype: return [{"message": inner or "", "dtype": dtype}] return [] + @property + def bar_rows(self) -> Bindings: + """The readings a bar draws, in every shape a document carries them. + + Current documents write ``rows``. Before that a bar drew one reading + with up to three nested inside it, which is read here as that reading + followed by the nested ones — the same set of messages, drawn as + separate tracks — so an older dashboard keeps working without being + migrated first. + """ + rows = self.config.get("rows") + if isinstance(rows, list): + return [row for row in rows if isinstance(row, dict)] + name = self.config.get("message") + outer: Bindings = ( + [{"message": name, "dtype": self.config.get("dtype")}] if name else [] + ) + return [*outer, *self.inner_bindings] + @property def messages(self) -> list[str]: """Every message name this widget reads.""" @@ -216,10 +253,13 @@ class WidgetDef(BaseModel): for series in self.config.get("series") or [] if series.get("message") ] + if self.type == "bar": + # A bar draws a row per reading, and each row binds its own. + return [ + str(row.get("message")) for row in self.bar_rows if row.get("message") + ] name = self.config.get("message") - # Only a bar nests further readings inside the one it draws. - nested = [s.get("message") for s in self.inner_bindings] - return [str(value) for value in (name, *nested) if value] + return [str(name)] if name else [] @property def target(self) -> str: @@ -256,10 +296,9 @@ class WidgetDef(BaseModel): str(series.get("dtype") or "") for series in self.config.get("series") or [] ] - return [ - str(self.config.get("dtype") or ""), - *(str(s.get("dtype") or "") for s in self.inner_bindings), - ] + if self.type == "bar": + return [str(row.get("dtype") or "") for row in self.bar_rows] + return [str(self.config.get("dtype") or "")] @model_validator(mode="after") def _check_binding(self) -> WidgetDef: @@ -273,9 +312,8 @@ class WidgetDef(BaseModel): ) return self - inner = self.config.get("inner") - if isinstance(inner, list) and len(inner) > BAR_SEGMENTS: - raise ValueError(f"a bar nests at most {BAR_SEGMENTS} readings") + if self.type == "bar" and len(self.bar_rows) > BAR_ROWS: + raise ValueError(f"a bar draws at most {BAR_ROWS} readings") if self.type == "color": # The row above allows both shapes a colour travels as; the format @@ -679,7 +717,7 @@ def default_dashboard(name: str) -> DashboardDef: __all__ = [ - "BAR_SEGMENTS", + "BAR_ROWS", "COLOR_DTYPES", "DASHBOARD_DIR", "HISTORY_CAP", diff --git a/backend/tests/flow/test_dashboards.py b/backend/tests/flow/test_dashboards.py index 9434a14..a1db9ee 100644 --- a/backend/tests/flow/test_dashboards.py +++ b/backend/tests/flow/test_dashboards.py @@ -284,6 +284,37 @@ def test_a_bar_is_drawn_on_both_readings_it_nests(): assert widget.messages == ["a.in", "a.pv"] +def test_a_bar_reads_its_rows(): + widget = WidgetDef( + id="b", + type="bar", + config={ + "rows": [ + {"message": "a.load", "dtype": "float", "label": "House"}, + {"message": "a.pv", "dtype": "int"}, + ], + "min": 0, + "max": 9, + }, + ) + + assert widget.messages == ["a.load", "a.pv"] + + with pytest.raises(ValueError): + WidgetDef( + id="b", + type="bar", + config={"rows": [{"message": "a.on", "dtype": "bool"}]}, + ) + + with pytest.raises(ValueError): + WidgetDef( + id="b", + type="bar", + config={"rows": [{"message": f"a.m{n}"} for n in range(9)]}, + ) + + def test_a_clock_reads_nothing_and_publishes_nothing(): widget = WidgetDef(id="c", type="clock", config={"format": "24h"}) diff --git a/docs/interface/dashboards.md b/docs/interface/dashboards.md index 6e8aaf0..18bceaa 100644 --- a/docs/interface/dashboards.md +++ b/docs/interface/dashboards.md @@ -28,7 +28,7 @@ and dragging is off. Picking a widget and editing its settings still works. | **Value** | anything | a formatted reading with a unit and a precision | | **Gauge** | `float`, `int` | min, max, unit | | **Chart** | `float`, `int` | up to five series; see *Two kinds of chart* below | -| **Bar** | `float`, `int` | a reading, optionally nesting up to three inside it | +| **Bar** | `float`, `int` | up to eight readings, a row each, with a scale per row | | **Icon** | numbers, booleans, weather strings | maps a value onto a glyph | | **Text** | — | markdown you write; a label, a note, an instruction | | **Agenda** | `list` | upcoming items, e.g. from a calendar connector | @@ -36,6 +36,11 @@ and dragging is off. Picking a widget and editing its settings still works. | **Notification** | `record` | title, body and severity — what an alert channel writes | | **Clock** | — | the time, in a size a wall can read | +Every widget carries a **title**, and **Show title** decides whether the panel +draws it. Turned off, the tile is just the reading — which is what a row of +gauges under one heading wants. The title is still the widget's name: what a +screen reader calls its controls, and what a published value is labelled with. + ### Input | Widget | Publishes | Notes | @@ -88,17 +93,21 @@ ignored rather than two charts overwriting each other's picture. ## Dashboard settings -Most of what a dashboard carries is a widget: a tile bound to a message. Two -things are not, because they belong to the whole surface rather than to any -tile on it — and a screen bolted to a wall has nobody standing at it to set -them. +Most of what a dashboard carries is a widget: a tile bound to a message. A +handful of things are not, because they belong to the whole surface rather than +to any tile on it — and a screen bolted to a wall has nobody standing at it to +set them. | Setting | Is | Driven by | |---|---|---| +| **Look** | `Material` or `Glass` | a `str` message | | **Theme** | `System`, `Light` or `Dark` | a `str` message | +| **Palette** | the dashboard's colours, in order | a `list` message | +| **Background** | the URL of an image | a `str` message | +| **Touch** | touch friendly on or off | a `bool` message | | **Lock** | read-only on or off | a `bool` message | -Both work the same way, and both halves are optional: +They all work the same way, and both halves are optional: - **Just a value.** Set Theme to `Dark` and that dashboard is dark wherever it is shown, whatever the device or the browser prefers. This costs no flow at @@ -115,9 +124,54 @@ nothing further. There is no schedule field, on purpose. **A schedule is a node publishing to the bound message**: an `inject` with a cron expression, feeding a `change` -node that maps the hour onto `"dark"` or `"light"`, is the whole of "dark after -sunset" — and the same channel then serves anything else you want to drive, -including locking a panel down remotely. +node that maps the hour onto a palette, is the whole of "warmer after sunset" — +and the same channel then serves anything else you want to drive, including +locking a panel down remotely. + +### Look + +**Material** lays flat, tonal cards on a plain ground. **Glass** floats +translucent tiles over a soft, slowly moving one. They are two complete sets of +components, not two stylesheets — but they are the same dashboard: every widget, +every control and every keystroke behaves identically, so switching look never +changes what a panel can do. + +### Palette + +A palette is an ordered list of colours, and **position is the role**: + +| # | Role | +|---|---| +| 1 | the ground the dashboard sits on | +| 2 | the surface a widget is | +| 3 | the primary — fills, active controls, the first chart line | +| 4 | the accent — the second chart line | +| 5 | text | +| 6+ | further chart colours | + +Paste a [coolors.co](https://coolors.co) link (or a list of hex colours) and the +whole dashboard is recoloured — every widget, the rail and the charts. Leave the +later roles off and they are worked out from the ones you gave, so **three +colours are a whole dashboard**. **Rotate** turns the list when the roles landed +in the wrong order. Text that could not be read on a surface is replaced with +black or white there, so no palette can produce a line nobody can see. + +While a palette is set, **Theme is idle**: the first colour is the ground, so +whether the panel reads light or dark is already decided by the palette itself. +Fault and success keep their own colours in every palette — a failure must never +be paintable as a reading. + +### Background + +An image drawn under the widgets, covering the canvas. It replaces the ground +the Glass look brings with it. Bound to a message, a flow decides the picture. + +### Touch + +Bigger controls, and nothing that only happens on hover. A phone gets this +anyway, from its own width; a wall panel has no way to say so for itself. + +### Lock Lock is a read-only *surface*, not a permission. The controls stay visible, stop publishing and read as disabled, and the panel says **Read-only** in the diff --git a/frontend/src/components/Common/UplotChart.tsx b/frontend/src/components/Common/UplotChart.tsx index 593c365..fa9867f 100644 --- a/frontend/src/components/Common/UplotChart.tsx +++ b/frontend/src/components/Common/UplotChart.tsx @@ -33,28 +33,6 @@ export const CHART_SLOTS = Array.from({ length: MAX_SERIES }, (_, index) => /** No palette named; a chart left with this spreads itself. */ export const NO_PALETTE: string[] = [] -/** - * A stored palette, made safe to draw with: a distinct subset, in draw order. - * - * Distinct on purpose, and worth keeping that way. Within the ramp only slot 1 - * against slot 5 clears the 3:1 guideline for non-text — 3.28 light, 3.37 dark, - * measured in `BarWidget.tsx` — so two traces on neighbouring slots are already - * close, and two on the *same* slot could not be told apart at all. Free - * assignment with repeats is not an improvement on this; it is the bug. - * - * Empty is the answer for a dashboard that names none, and for anything - * unrecognised: no palette, so the chart spreads itself — see `slotsFor`. - */ -export function paletteOf(value: unknown): string[] { - if (!Array.isArray(value)) return NO_PALETTE - const slots = [...new Set(value.map(String))].filter((slot) => - CHART_SLOTS.includes(slot), - ) - // One shared array for the common answer, so a dashboard that names no - // palette does not hand its readers a new one on every render. - return slots.length > 0 ? slots : NO_PALETTE -} - /** * What a chart of *n* lines draws with when no palette was named: the ramp * spread, rather than its first *n* steps. @@ -183,15 +161,29 @@ const PADDING: uPlot.Padding = [10, 12, 0, 0] const canvasHeight = (element: HTMLElement) => Math.max(60, element.clientHeight || 180) -/** A token, resolved for the canvas — which cannot read CSS variables. */ -function token(name: string): string { - return getComputedStyle(document.documentElement) - .getPropertyValue(name) - .trim() +/** + * A token, resolved for the canvas — which cannot read CSS variables. + * + * Read off the chart's own element rather than the document: a dashboard + * states its colours on the canvas it is drawn on, and custom properties + * inherit, so a tile inside one has to be asked where it stands. Asking the + * root would draw the shell's axes on the dashboard's chart. + */ +function token(name: string, from: Element): string { + return getComputedStyle(from).getPropertyValue(name).trim() } -const seriesColor = (index: number, slots: string[]) => - token(`--chart-${slots[index % slots.length]}`) +/** + * What one line is drawn in. + * + * A palette entry is either a slot of the app's own ramp — the shape a + * dashboard stored before it could name colours — or a colour, which is what a + * palette writes today. A colour is used as it stands. + */ +const seriesColor = (index: number, slots: string[], from: Element) => { + const slot = slots[index % slots.length] + return CHART_SLOTS.includes(slot) ? token(`--chart-${slot}`, from) : slot +} /** * Tick labels that stay distinct. @@ -262,8 +254,8 @@ export function UplotChart({ /** A named y axis; the unit stays on the ticks. */ yLabel?: string /** The ramp slots this chart's lines take, in order — the dashboard's own - * palette. Unset, the chart spreads itself across the ramp by how many - * lines it draws, which is what one outside a dashboard (Health, Home) + * palette — colours, or slots of the app's own ramp. Unset, the chart + * spreads itself across the ramp by how many lines it draws, which is what one outside a dashboard (Health, Home) * always does. */ palette?: string[] /** Draw the lines as a monotone cubic spline rather than straight segments. @@ -304,9 +296,9 @@ export function UplotChart({ if (!element || labels.length === 0 || !ready) return const axis = { - stroke: () => token("--muted-foreground"), - grid: { stroke: () => token("--border"), width: 1 }, - ticks: { stroke: () => token("--border"), width: 1 }, + stroke: () => token("--muted-foreground", element), + grid: { stroke: () => token("--border", element), width: 1 }, + ticks: { stroke: () => token("--border", element), width: 1 }, font: `11px ${getComputedStyle(element).fontFamily}`, } /** The x value the page was last told about, so a move within one bucket @@ -381,7 +373,7 @@ export function UplotChart({ width: 2, // Read at draw time, so a theme toggle is a redraw rather than a // rebuilt chart. - stroke: () => seriesColor(index, slots), + stroke: () => seriesColor(index, slots, element), // The cursor readout is what decides how wide the legend gets, so // it is shortened here; a named unit is short enough to keep. Four // figures rather than three: this is the number someone is pointing diff --git a/frontend/src/components/Dashboard/BarWidget.tsx b/frontend/src/components/Dashboard/BarWidget.tsx index e14574b..6a3e67b 100644 --- a/frontend/src/components/Dashboard/BarWidget.tsx +++ b/frontend/src/components/Dashboard/BarWidget.tsx @@ -1,181 +1,42 @@ -import { displayName, flowOf } from "@/components/Flow/deriveEdges" -import { useLiveValue } from "@/components/Flow/liveStore" -import { cn } from "@/lib/utils" +import { slotsFor } from "@/components/Common/UplotChart" +import { useLiveValues } from "@/components/Flow/liveStore" +import { usePalette } from "./settings" +import { useUi } from "./ui" +import { rowsOf } from "./ui/core/config" +import { barReadings } from "./ui/core/values" import type { WidgetProps } from "./widgets" -// Local copies: `widgets.tsx` imports this module, so its helpers cannot be -// imported back without closing the cycle (`ChartWidget.tsx` does the same). -const config = (widget: WidgetProps["widget"]) => - (widget.config ?? {}) as Record - -const text = (value: unknown) => (value == null ? "" : String(value)) - -const num = (value: unknown, fallback: number) => { - const parsed = Number(value) - return Number.isFinite(parsed) ? parsed : fallback -} - -/** Below this the reading no longer fits on the fill and moves off its end. */ -const FITS = 0.3 - /** - * How many readings a bar nests, and a hard ceiling. + * Several readings on one tile, a row each. * - * The limit is contrast rather than layout. No slot of the chart ramp reaches - * 3:1 against `--primary`, so every segment is drawn in the one token that - * does — `--primary-nested` — and neighbours are told apart by the gutter of - * outer fill left between them. Within the ramp only `--chart-1` against - * `--chart-5` clears 3:1 (3.28 light / 3.37 dark) and only slots 1-3 clear it - * against the `--muted` track, so a fourth segment could not be told from its - * neighbour without breaking the very guideline this stacking exists to fix. - */ -export const MAX_SEGMENTS = 3 - -/** Outer fill left around a segment, as `inset-y-1` leaves it above and below. */ -const GUTTER = "2px" - -export type Segment = { message?: string; dtype?: string; label?: string } - -/** A segment as drawn: where it runs on the fill, and what it reads. */ -type Band = { start: number; end: number; level: number; name: string } - -/** - * The nested readings, in either shape a document may carry them: one binding, - * as a bar was written before it stacked, or an ordered list of them. - */ -export const segmentsOf = (widget: WidgetProps["widget"]): Segment[] => { - const cfg = config(widget) - if (Array.isArray(cfg.inner)) - return (cfg.inner as Segment[]).slice(0, MAX_SEGMENTS) - return cfg.inner - ? [ - { - message: text(cfg.inner), - dtype: text(cfg.inner_dtype), - label: text(cfg.inner_label), - }, - ] - : [] -} - -/** - * A level, drawn as a horizontal bar with its reading written on it. + * It used to be one reading with up to three nested inside its own fill — the + * PV share of an inverter's input drawn within the input. That said one thing + * well, containment, and everything else badly: the nested readings all had to + * share a single colour to stay legible against the fill, three was the + * ceiling, and none of them could carry a scale of its own. * - * Further messages can be nested inside the first — the PV share of an - * inverter's input — and are drawn on the fill on the same scale, stacked end - * to end so that they partition the reading rather than cover one another. - * None of them leaves the fill, so containment is still what the picture - * shows rather than something to work out. + * A row each says the same thing where it is true — two rows on one scale + * still read as a share, because the shorter bar *is* the smaller part — and + * says the things the old shape could not: a battery percentage beside a load + * in kW, told apart by the dashboard's own data colours rather than by a + * gutter. Documents written the old way are read as rows (`rowsOf`). */ export function BarWidget({ widget }: WidgetProps) { - const cfg = config(widget) - const message = text(cfg.message) - const segments = segmentsOf(widget) - const outer = useLiveValue(message || undefined) - // One hook per slot rather than one per segment, so the number of hooks - // React sees never moves with the configuration. MAX_SEGMENTS is its length. - const nested = [ - useLiveValue(segments[0]?.message || undefined), - useLiveValue(segments[1]?.message || undefined), - useLiveValue(segments[2]?.message || undefined), - ] - if (!message) - return

Pick a message.

+ const { Bar } = useUi() + const rows = rowsOf(widget).filter((row) => row.message) + const live = useLiveValues(rows.map((row) => row.message as string)) + // The dashboard's own data colours, in the order a chart would take them, so + // a bar and a chart of the same readings agree about which line is which. + const colors = slotsFor(rows.length, usePalette()) - const min = num(cfg.min, 0) - const max = num(cfg.max, 100) - const span = max - min || 1 - const precision = cfg.precision === undefined ? 1 : num(cfg.precision, 1) - const unit = cfg.unit ? text(cfg.unit) : "" - - const reading = (live: unknown) => (typeof live === "number" ? live : null) - const fractionOf = (value: number | null) => - value === null ? 0 : Math.min(1, Math.max(0, (value - min) / span)) - const write = (value: number | null) => - value === null ? "—" : `${value.toFixed(precision)}${unit}` - - const value = reading(outer?.value) - const fraction = fractionOf(value) - const fits = fraction >= FITS - - // Each segment starts where the one before it ended, and the run is clamped - // to the outer fill: a nested value larger than the reading it is part of - // used to spill onto the track and read as more than the whole. - const drawn: Band[] = [] - let cursor = 0 - for (const [index, segment] of segments.entries()) { - const name = segment.message ?? "" - const level = name ? reading(nested[index]?.value) : null - if (level === null) continue - const start = cursor - cursor = Math.min(fraction, start + fractionOf(level)) - drawn.push({ - start, - end: cursor, - level, - // The panel already carries the widget's title, so the caption names the - // reading by its port rather than repeating the flow it comes from — or - // by whatever the author called it, since a port name is chosen for the - // graph and not for somebody reading it across a room. - name: text(segment.label) || displayName(flowOf(name), name), - }) + if (rows.length === 0) { + return

Pick a message.

} - const detail = drawn - .map((band) => `${write(band.level)} of it from ${band.name}`) - .join(", ") return ( -
-
-
- {drawn.map((band, index) => ( -
- ))} - {/* Always anchored to the end of the fill by `left`, so the whole - travel is one interpolating property. Which side of that anchor the - reading sits on is the translate: pulled back onto the fill while - there is room for it, left where it is once there is not. */} - - {write(value)} - -
- {drawn.length === 0 ? null : ( -

- {drawn.map((band) => `${band.name} ${write(band.level)}`).join(" · ")} -

- )} -
+ ) } diff --git a/frontend/src/components/Dashboard/ChartWidget.tsx b/frontend/src/components/Dashboard/ChartWidget.tsx index c434886..d494fc9 100644 --- a/frontend/src/components/Dashboard/ChartWidget.tsx +++ b/frontend/src/components/Dashboard/ChartWidget.tsx @@ -6,13 +6,13 @@ import { DEFAULT_RANGE, RANGES, type Range, - RangePicker, } from "@/components/Common/RangePicker" import { MAX_SERIES, UplotChart } from "@/components/Common/UplotChart" -import { useLiveValue } from "@/components/Flow/liveStore" +import { useLiveValue, useLiveValues } from "@/components/Flow/liveStore" import { messageHistoryQueryOptions, usePublishMessage } from "./queries" import { usePalette } from "./settings" -import { type Series, useHeaderSlot, type WidgetProps } from "./widgets" +import { useUi } from "./ui" +import type { Series, WidgetProps } from "./widgets" // The five-series ceiling is the chart host's, and the panel reads it here. export { MAX_SERIES } @@ -128,14 +128,7 @@ function LiveChart({ widget }: WidgetProps) { queries: names.map((name) => messageHistoryQueryOptions(name)), }) - // A fixed set: hooks cannot be called in a loop, and five is the ceiling. - const live = [ - useLiveValue(names[0]), - useLiveValue(names[1]), - useLiveValue(names[2]), - useLiveValue(names[3]), - useLiveValue(names[4]), - ] + const live = useLiveValues(names) // What arrived over the socket since the history was fetched. Appending beats // refetching the whole series every time a value lands. @@ -179,7 +172,7 @@ function LiveChart({ widget }: WidgetProps) { const labels = series.map((entry, index) => entry.label || names[index]) if (names.length === 0) { - return

Pick a message.

+ return

Pick a message.

} return ( @@ -269,30 +262,54 @@ function QueryChart({ widget, dashboard }: WidgetProps) { // signal that something arrived — no timestamp needed beside it. }, [live?.value, request, rangeS, intervalS]) - // Drawn on the title's line rather than above the plot: a tile is short, and - // a row of its own costs the chart about a tenth of its height. Nothing to - // pick a window for until the chart is wired, so a half-configured tile says - // that and nothing else. - const bound = Boolean(request && message) - useHeaderSlot( - bound ? : null, - [range, bound], - ) - if (!request || !message) { - return

Pick a message.

+ return

Pick a message.

} const lines = (answer?.lines ?? []).slice(0, MAX_SERIES) return ( - line.label || `Series ${index + 1}`)} - plots={lines.map((line) => - (line.points ?? []).map(([ts, value]) => ({ ts, value })), + // The window sits down the right-hand edge rather than on the title's + // line: a tile is short, and every row the chrome takes is a row the plot + // does not get. A column costs it about a fortieth of its width instead. +
+ line.label || `Series ${index + 1}`)} + plots={lines.map((line) => + (line.points ?? []).map(([ts, value]) => ({ ts, value })), + )} + empty="Waiting for an answer." + palette={palette} + {...presentation(cfg)} + /> + +
+ ) +} + +/** The window a chart is showing, as a column beside it. */ +function RangeRail({ + range, + onChange, +}: { + range: Range + onChange: (range: Range) => void +}) { + const { Segmented } = useUi() + return ( + [String(entry.hours), entry.label] as const, )} - empty="Waiting for an answer." - palette={palette} - {...presentation(cfg)} + onChange={(hours) => + onChange( + RANGES.find((entry) => String(entry.hours) === hours) ?? + DEFAULT_RANGE, + ) + } /> ) } diff --git a/frontend/src/components/Dashboard/ClockWidget.tsx b/frontend/src/components/Dashboard/ClockWidget.tsx index 2b81ab4..1da9a46 100644 --- a/frontend/src/components/Dashboard/ClockWidget.tsx +++ b/frontend/src/components/Dashboard/ClockWidget.tsx @@ -18,13 +18,13 @@ export function ClockWidget(_props: WidgetProps) { return (
-

+

{now.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", })}

-

+

{now.toLocaleDateString(undefined, { weekday: "long", day: "numeric", diff --git a/frontend/src/components/Dashboard/ColorWidget.tsx b/frontend/src/components/Dashboard/ColorWidget.tsx index c025e5b..5701ee2 100644 --- a/frontend/src/components/Dashboard/ColorWidget.tsx +++ b/frontend/src/components/Dashboard/ColorWidget.tsx @@ -1,329 +1,72 @@ import { useState } from "react" -import type { WidgetDef } from "@/client" -// The wheel's own sizing rule lives beside the other widget CSS. -import "./dashboard.css" import { usePublish } from "./publish" +import { useUi } from "./ui" +import { + colorFormatOf, + decodeColor, + encodeColor, + type Triple, + UNSET, +} from "./ui/core/color" import type { WidgetProps } from "./widgets" -/** Three numbers: a colour in whichever of the two triples is meant. */ -export type Triple = [number, number, number] - -export type ColorFormat = "hsv" | "rgb" | "hex" +// Re-exported: the conversions moved below the component sets so the disc's +// own maths could use them, and the editor and the checks still ask here. +export { + COLOR_DTYPES, + COLOR_FORMATS, + type ColorFormat, + colorFormatOf, + decodeColor, + encodeColor, + hsvToRgb, + rgbToHsv, + type Triple, +} from "./ui/core/color" /** - * What each format puts on the wire, by payload type. + * A colour, set on a disc and published as one message. * - * Mirrored on the server (`COLOR_DTYPES` in `app/flow/dashboards.py`), which - * refuses a binding the format cannot carry. - */ -export const COLOR_DTYPES: Record = { - hsv: "list", - rgb: "list", - hex: "str", -} - -/** The formats, as the editor offers them. */ -export const COLOR_FORMATS = [ - ["hsv", "HSV"], - ["rgb", "RGB"], - ["hex", "Hex"], -] as const - -/** Which format this widget sends. Anything unrecorded is the default. */ -export function colorFormatOf(widget: WidgetDef): ColorFormat { - const format = widget.config?.format - return format === "rgb" || format === "hex" ? format : "hsv" -} - -/** How far one arrow key moves the hue. A degree at a time is 360 presses. */ -const HUE_STEP = 5 - -/** Nothing published yet: white at full brightness, which is a lamp that is on. */ -const UNSET: Triple = [0, 0, 100] - -/** Middle of the ring, in percent of the wheel, where the handle rides. */ -const RING_RADIUS = 39 - -/** - * The hue ring, as one CSS gradient rather than a canvas repainted per frame. + * Hue is the angle and saturation the distance from the middle, so the whole + * of a colour's chroma is one gesture on one picture: white at the centre, + * fully saturated at the rim. Brightness is the slider beside it — a disc can + * carry two components, and brightness is the one a lamp is usually turned + * down by on its own. * - * `from 0deg` starts at twelve o'clock and runs clockwise, which is the frame - * the pointer and keyboard maths below share. These are the only colours in - * this file that are not tokens, deliberately: a hue wheel paints the value it - * publishes rather than the palette (root DESIGN-GUIDELINES.md → Colour). - */ -const HUE_RING = `conic-gradient(from 0deg, ${[0, 60, 120, 180, 240, 300, 360] - .map((hue) => `hsl(${hue} 100% 50%)`) - .join(", ")})` - -const clamp = (value: number, high: number) => - Math.min(high, Math.max(0, Math.round(value))) - -/** A hue is an angle: 370 degrees is 10, and -10 is 350. */ -const wrap = (hue: number) => ((Math.round(hue) % 360) + 360) % 360 - -/** - * HSV to RGB — the same conversion the reference's DMX encoders do, so a - * fixture wired to `rgb` gets what one wired to `hsv` works out for itself. + * It replaces a hue *ring* with saturation on a slider of its own. The ring + * could only ever read an angle, so two of the three components were sliders + * and setting a colour took three gestures rather than one. * - * Hue 0-360 degrees, saturation and value 0-100 percent in; three 0-255 - * channels out. - */ -export function hsvToRgb([hue, saturation, value]: Triple): Triple { - const level = clamp(value, 100) / 100 - const chroma = level * (clamp(saturation, 100) / 100) - const sector = (((hue % 360) + 360) % 360) / 60 - const second = chroma * (1 - Math.abs((sector % 2) - 1)) - const base = level - chroma - const [red, green, blue] = - sector < 1 - ? [chroma, second, 0] - : sector < 2 - ? [second, chroma, 0] - : sector < 3 - ? [0, chroma, second] - : sector < 4 - ? [0, second, chroma] - : sector < 5 - ? [second, 0, chroma] - : [chroma, 0, second] - const channel = (part: number) => Math.round((part + base) * 255) - return [channel(red), channel(green), channel(blue)] -} - -/** The way back, for a colour some flow set rather than this wheel. */ -export function rgbToHsv(rgb: Triple): Triple { - const [red, green, blue] = rgb.map((channel) => clamp(channel, 255) / 255) - const high = Math.max(red, green, blue) - const spread = high - Math.min(red, green, blue) - let hue = 0 - if (spread) { - hue = - high === red - ? ((green - blue) / spread) % 6 - : high === green - ? (blue - red) / spread + 2 - : (red - green) / spread + 4 - hue = (hue * 60 + 360) % 360 - } - return [ - Math.round(hue), - Math.round(high ? (spread / high) * 100 : 0), - Math.round(high * 100), - ] -} - -const toHex = (rgb: Triple) => - `#${rgb.map((channel) => clamp(channel, 255).toString(16).padStart(2, "0")).join("")}` - -const fromHex = (text: string): Triple | null => { - const digits = /^#?([0-9a-f]{6})$/i.exec(text)?.[1] - if (!digits) return null - const at = (index: number) => - Number.parseInt(digits.slice(index * 2, index * 2 + 2), 16) - return [at(0), at(1), at(2)] -} - -/** What this control publishes, in the format its config picked. */ -export function encodeColor(hsv: Triple, format: ColorFormat): unknown { - if (format === "hsv") return hsv - const rgb = hsvToRgb(hsv) - return format === "rgb" ? rgb : toHex(rgb) -} - -/** - * What came back over the socket, as the wheel's own three numbers. - * - * Null for anything that is not a colour in this format — nothing published - * yet, or a flow that answered with something else. - */ -export function decodeColor( - value: unknown, - format: ColorFormat, -): Triple | null { - if (format === "hex") { - const rgb = typeof value === "string" ? fromHex(value) : null - return rgb && rgbToHsv(rgb) - } - if (!Array.isArray(value) || value.length < 3) return null - const [first, second, third] = value.slice(0, 3).map(Number) - if (![first, second, third].every(Number.isFinite)) return null - if (format === "rgb") return rgbToHsv([first, second, third]) - return [wrap(first), clamp(second, 100), clamp(third, 100)] -} - -/** The colour a set of three makes, for the swatch and the handle. */ -const cssOf = (hsv: Triple) => `rgb(${hsvToRgb(hsv).join(" ")})` - -/** Where the handle sits: hue as an angle, clockwise from the top. */ -const handleAt = (hue: number) => ({ - left: `${50 + RING_RADIUS * Math.sin((hue * Math.PI) / 180)}%`, - top: `${50 - RING_RADIUS * Math.cos((hue * Math.PI) / 180)}%`, -}) - -/** One of the two components under the wheel, named and with its reading. */ -function Level({ - label, - value, - disabled, - onChange, - onCommit, -}: { - label: string - value: number - disabled?: boolean - onChange: (value: number) => void - onCommit: () => void -}) { - return ( - - ) -} - -/** - * A colour, set on a wheel and published as one message. - * - * The ring is a conic gradient rather than a canvas, so moving the handle - * repaints nothing. Sized for a finger — the ring is roughly a fifth of the - * wheel wide and the sliders keep their 44px target — and reachable without - * one: the ring is a slider in its own right, with arrow keys on the hue and - * two labelled sliders under it. - * - * ponytail: the ring reads the angle only, never how far from the centre the - * finger is, so saturation stays a slider rather than the radius of a disc. - * The ceiling is a colour set in one gesture; a disc would put two values on a - * control that can announce one, and neither of them on a keyboard. + * Only the release publishes, as a slider does: a drag would otherwise send a + * value per pixel and flood whatever is listening. */ export function ColorWidget({ widget, dashboard }: WidgetProps) { + const { ColorDisk } = useUi() const { target, value, send, pulse, locked } = usePublish(widget, dashboard) - // While dragging, the wheel follows the finger rather than the engine. + // While dragging, the disc follows the finger rather than the engine. const [draft, setDraft] = useState(null) - if (!target) - return

Pick a message.

+ if (!target) return

Pick a message.

const format = colorFormatOf(widget) const current = draft ?? decodeColor(value, format) ?? UNSET - const [hue, saturation, brightness] = current - const name = widget.title || target - - const commit = () => { - if (draft === null) return - send(encodeColor(draft, format)) - setDraft(null) - } - - /** The hue under the pointer: where it is relative to the wheel's centre. */ - const aim = (event: React.PointerEvent) => { - if (locked) return - const box = event.currentTarget.getBoundingClientRect() - const x = event.clientX - (box.left + box.width / 2) - const y = event.clientY - (box.top + box.height / 2) - const degrees = (Math.atan2(y, x) * 180) / Math.PI + 90 - setDraft([wrap(degrees), saturation, brightness]) - } return ( - // The pulse hangs off the frame, so it stays outside every box below: - // both the wheel's and the tile's own are query containers, and a - // container is a containing block for anything absolute inside it. + // The pulse hangs off the frame, so it stays outside every box below. <> {pulse} -
- {/* Wheel above the components, or beside them once the tile is wider - than it is tall — the shape a wall panel's rows usually are. */} -
-
- {/* A ring is not a range input and a native one cannot be bent into a - circle, so it says what it is and answers the same keys. */} -
{ - event.currentTarget.setPointerCapture(event.pointerId) - aim(event) - }} - onPointerMove={(event) => { - if (event.currentTarget.hasPointerCapture(event.pointerId)) - aim(event) - }} - onPointerUp={commit} - onKeyDown={(event) => { - if (locked) return - const step = - event.key === "ArrowRight" || event.key === "ArrowUp" - ? HUE_STEP - : event.key === "ArrowLeft" || event.key === "ArrowDown" - ? -HUE_STEP - : 0 - if (!step) return - event.preventDefault() - setDraft([wrap(hue + step), saturation, brightness]) - }} - onKeyUp={commit} - > - {/* What the three components add up to, drawn where a wheel is - usually read: in the middle. */} - - -
-
-
- setDraft([hue, next, brightness])} - onCommit={commit} - /> - setDraft([hue, saturation, next])} - onCommit={commit} - /> -
-
+
+ { + if (draft === null) return + send(encodeColor(draft, format)) + setDraft(null) + }} + />
) diff --git a/frontend/src/components/Dashboard/DashboardEditor.tsx b/frontend/src/components/Dashboard/DashboardEditor.tsx index 4ed787d..27635a3 100644 --- a/frontend/src/components/Dashboard/DashboardEditor.tsx +++ b/frontend/src/components/Dashboard/DashboardEditor.tsx @@ -78,18 +78,56 @@ import { usePublishDashboard, useSaveDashboard, } from "./queries" -import { PaletteProvider, useDashboardTheme } from "./settings" +import { PaletteProvider } from "./settings" +import { useUi } from "./ui" +import { showTitle } from "./ui/core/config" +import { LookProvider, useCanvasRoot } from "./ui/core/look" import { WIDGET_LABELS, WIDGET_SIZES, WidgetBody, - WidgetFrame, type WidgetKind, widgetIssue, } from "./widgets" const KINDS = Object.keys(WIDGET_LABELS) as WidgetKind[] +/** + * A widget as the editor draws it: the panel's own look, plus a click that + * picks it. + * + * Its own component because it draws with the look's components, which are + * only knowable below `LookProvider`. + */ +function Pickable({ + widget, + dashboard, + grip, + selected, + onPick, +}: { + widget: WidgetDef + dashboard: string + grip: boolean + selected: boolean + onPick: () => void +}) { + const { Frame } = useUi() + return ( + { + if (!(event.target as Element).closest(INTERACTIVE)) onPick() + }} + > + + + ) +} + /** How long to sit on edits before saving, so typing is not a save per key. */ const AUTOSAVE_MS = 800 @@ -212,10 +250,10 @@ export function DashboardEditor({ // A phone reads the dashboard rather than arranges it, so the grid library // never mounts there. See DESIGN-GUIDELINES.md → Responsive. const stacked = useIsMobile() - // The dashboard's own theme, on the surface only: the shell around the + // The dashboard's own appearance, on the surface only: the shell around the // canvas stays whatever the person editing chose for the app. Non-stacked, // `CanvasSurface` states it on the canvas box itself. - const theme = useDashboardTheme(draft) + const root = useCanvasRoot(draft) const navigate = useNavigate() const queryClient = useQueryClient() const save = useSaveDashboard(dashboard.name) @@ -414,28 +452,16 @@ export function DashboardEditor({ /** A widget that can be picked to open its settings. */ const pickable = (widget: WidgetDef, grip = false) => ( - { - if (!(event.target as Element).closest(INTERACTIVE)) { - setSettingsOpen(false) - setSelected(widget.id) - } + selected={widget.id === selected} + onPick={() => { + setSettingsOpen(false) + setSelected(widget.id) }} - > - - + /> ) const body = !page ? ( @@ -450,10 +476,14 @@ export function DashboardEditor({ // One column at the viewport's width. Edit mode still picks a widget and // opens its settings; only the arrangement is missing.
+ {railDashboards && !stacked ? ( ) : null} - + ) } diff --git a/frontend/src/components/Dashboard/DashboardView.tsx b/frontend/src/components/Dashboard/DashboardView.tsx index bc59a31..0233e10 100644 --- a/frontend/src/components/Dashboard/DashboardView.tsx +++ b/frontend/src/components/Dashboard/DashboardView.tsx @@ -1,5 +1,5 @@ +import { motion } from "motion/react" import { useEffect, useRef, useState } from "react" - import type { DashboardDef_Output, PageDef_Output, @@ -7,10 +7,19 @@ import type { SectionDef_Output, WidgetDef, } from "@/client" + import { cn } from "@/lib/utils" import "./dashboard.css" -import { LockedProvider, PaletteProvider, useDashboardTheme } from "./settings" -import { WidgetBody, WidgetFrame, widgetIssue } from "./widgets" +import { + LockedProvider, + PaletteProvider, + useDashboardBackground, +} from "./settings" +import { useUi } from "./ui" +import { 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" export type Dashboard = DashboardDef_Output @@ -97,10 +106,10 @@ export const rowsOf = (dashboard: Dashboard) => * narrower screen, changes only the scale — the arrangement being designed, * and the dot grid under it, stay the layout the panel will actually show. * - * This box is also exactly what the dashboard's `theme` setting applies to: it - * is the panel, so a dashboard forced light or dark paints its own ground here - * and leaves the shell around it alone. Which is why it states a background at - * all — without one it would borrow whatever it was dropped into. + * This box is also exactly what a dashboard's own appearance applies to: it is + * the panel, so its palette, its look and its light or dark all land here and + * leave the shell around it alone. Which is why it states a background at all + * — without one it would borrow whatever it was dropped into. */ export function CanvasSurface({ dashboard, @@ -130,42 +139,65 @@ export function CanvasSurface({ const { width, height } = canvasOf(dashboard) const scale = Math.min(box.width / width, box.height / height) - const theme = useDashboardTheme(dashboard) + const root = useCanvasRoot(dashboard) return (
{/* Measured first: a guessed scale would place the whole panel once and then move it. */} {scale > 0 ? ( -
- {children(scale)} -
+ +
+ + {/* 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}
) } +/** + * What the widgets sit on. + * + * The image a dashboard was given, or whatever ground its look brings — glass + * needs something behind it to refract, and material is happy with the surface + * colour alone. + */ +export function CanvasGround({ dashboard }: { dashboard: Dashboard }) { + const { Backdrop } = useUi() + return +} + export function placement(widget: WidgetDef): Placement { const layout = (widget.layout ?? {}) as Record return layout.lg ?? layout.md ?? layout.sm ?? {} @@ -303,37 +335,80 @@ export function DashboardView({ const columns = columnsOf(dashboard) return ( - - -
+ + + + + + + ) +} + +/** + * The arrangement itself. + * + * Its own component because it draws with the look's components, which are + * only knowable below `LookProvider`. Widgets arrive as a page rather than all + * at once — a stagger, at whatever stiffness the look moves with. + */ +function WidgetGrid({ + dashboard, + widgets, + columns, + stacked, + renderWidget, +}: { + dashboard: Dashboard + widgets: WidgetDef[] + columns: number + stacked?: boolean + renderWidget?: (widget: WidgetDef) => React.ReactNode +}) { + const { Frame } = useUi() + const { look } = useLook() + return ( + + {widgets.map((widget) => ( + - {widgets.map((widget) => ( -
- {renderWidget ? ( - renderWidget(widget) - ) : ( - - - - )} -
- ))} -
-
-
+ + + )} + + ))} + ) } diff --git a/frontend/src/components/Dashboard/ForecastWidget.tsx b/frontend/src/components/Dashboard/ForecastWidget.tsx index c58fa5a..239e850 100644 --- a/frontend/src/components/Dashboard/ForecastWidget.tsx +++ b/frontend/src/components/Dashboard/ForecastWidget.tsx @@ -1,6 +1,7 @@ import { useLiveValue } from "@/components/Flow/liveStore" import { cn } from "@/lib/utils" import { ICON_COLORS, ICONS } from "./icons" +import { config } from "./ui/core/config" import type { WidgetProps } from "./widgets" /** One column of a forecast, as the `list` message declares it. */ @@ -11,9 +12,6 @@ type ForecastItem = { color?: string } -const config = (widget: WidgetProps["widget"]) => - (widget.config ?? {}) as Record - /** * What comes next, as a strip of columns over a `list` message. * @@ -28,7 +26,7 @@ export function ForecastWidget({ widget }: WidgetProps) { const message = cfg.message ? String(cfg.message) : "" const live = useLiveValue(message || undefined) if (!message) { - return

Pick a message.

+ return

Pick a message.

} const count = Number(cfg.count) @@ -37,7 +35,7 @@ export function ForecastWidget({ widget }: WidgetProps) { .slice(0, Number.isFinite(count) ? count : 5) if (items.length === 0) { - return

Nothing forecast.

+ return

Nothing forecast.

} return ( @@ -56,18 +54,18 @@ export function ForecastWidget({ widget }: WidgetProps) { // ramp out of an arbitrary Tailwind class. style={{ opacity: Math.max(0.45, 1 - index * 0.14) }} > - + {item.label ?? ""} {Glyph ? ( ) : null} - + {item.value === null || item.value === undefined ? "" : String(item.value)} diff --git a/frontend/src/components/Dashboard/IconWidget.tsx b/frontend/src/components/Dashboard/IconWidget.tsx index 1c8ec7d..a6d2deb 100644 --- a/frontend/src/components/Dashboard/IconWidget.tsx +++ b/frontend/src/components/Dashboard/IconWidget.tsx @@ -1,18 +1,12 @@ -import type { WidgetDef } from "@/client" import { useLiveValue } from "@/components/Flow/liveStore" import { cn } from "@/lib/utils" import { ICON_COLORS, ICONS } from "./icons" +import { config, text } from "./ui/core/config" import type { WidgetProps } from "./widgets" /** One row of the mapping, as the document stores it. */ type Rule = { at?: unknown; icon?: string; color?: string; label?: string } -const config = (widget: WidgetDef) => - (widget.config ?? {}) as Record - -const text = (value: unknown, fallback = "") => - value === null || value === undefined ? fallback : String(value) - /** What a mapped value was meant to be: a bool, a number, or the text itself. */ function coerce(raw: string): unknown { const asNumber = Number(raw) @@ -50,18 +44,18 @@ export function IconWidget({ widget }: WidgetProps) { const Glyph = ICONS[matched?.icon ?? text(cfg.icon)] ?? null // Unbound, unmatched, or pointed at a glyph that is not in the map. - if (!Glyph) return

+ if (!Glyph) return

return (
{matched?.label ? ( - + {matched.label} ) : null} diff --git a/frontend/src/components/Dashboard/PanelRail.tsx b/frontend/src/components/Dashboard/PanelRail.tsx index d75cf85..6503dcc 100644 --- a/frontend/src/components/Dashboard/PanelRail.tsx +++ b/frontend/src/components/Dashboard/PanelRail.tsx @@ -1,28 +1,13 @@ import { useQueries } from "@tanstack/react-query" -import { Link, type LinkProps } from "@tanstack/react-router" +import type { LinkProps } from "@tanstack/react-router" -import { ICONS } from "@/components/Dashboard/icons" import { dashboardQueryOptions } from "@/components/Dashboard/queries" -import { Button } from "@/components/ui/button" -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip" -import { cn } from "@/lib/utils" +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" -/** Two letters off the title, so a rail of four reads as four different things. */ -function initials(label: string): string { - const words = label.split(/[\s_-]+/).filter(Boolean) - if (words.length === 0) return "?" - if (words.length === 1) return words[0].slice(0, 2).toUpperCase() - return (words[0][0] + words[1][0]).toUpperCase() -} - /** * Switching between the dashboards one panel was assigned. * @@ -32,6 +17,9 @@ function initials(label: string): string { * the grid itself never changes, so an arrangement made without the rail still * fits with it. * + * Drawn in the dashboard's own look, because it hangs on the same screen: a + * rail wearing the app's chrome beside a glass panel is two designs at once. + * * Reading the dashboards it links to is also what fills the labels, and it * warms the cache for the neighbours so a switch draws immediately. */ @@ -39,68 +27,23 @@ export function PanelRail({ dashboards, current, linkFor, - className, }: { dashboards: string[] current: string linkFor: (name: string) => LinkProps - className?: string }) { + const { Rail } = useUi() const entries = useQueries({ queries: dashboards.map((name) => dashboardQueryOptions(name)), combine: (results) => results.map((result, index) => ({ + name: dashboards[index], label: result.data?.title || dashboards[index], icon: result.data?.icon ?? "", + active: dashboards[index] === current, + link: linkFor(dashboards[index]), })), }) - return ( - - ) + return } diff --git a/frontend/src/components/Dashboard/PanelSurface.tsx b/frontend/src/components/Dashboard/PanelSurface.tsx index 93c960b..ac8853e 100644 --- a/frontend/src/components/Dashboard/PanelSurface.tsx +++ b/frontend/src/components/Dashboard/PanelSurface.tsx @@ -1,11 +1,11 @@ -import { Lock } from "lucide-react" - import type { Dashboard } from "@/components/Dashboard/DashboardView" import { CanvasSurface, DashboardView, } from "@/components/Dashboard/DashboardView" import { useDashboardLocked } from "@/components/Dashboard/settings" +import { useUi } from "@/components/Dashboard/ui" +import { LookProvider } from "@/components/Dashboard/ui/core/look" /** * One dashboard filling whatever screen it landed on. @@ -38,7 +38,10 @@ export function PanelSurface({ {() => } )} - + {/* Outside the canvas, so it needs the look stated for it. */} + + +
) } @@ -55,20 +58,10 @@ export function PanelSurface({ * with the lock rather than with the page load. */ function LockNotice({ dashboard }: { dashboard: Dashboard }) { + const { Notice } = useUi() // Read straight from the document rather than through `useLocked`: that // provider sits inside the grid, and this notice is beside it. const locked = useDashboardLocked(dashboard) if (!locked) return null - return ( -
- - Read-only -
- ) + return Read-only } diff --git a/frontend/src/components/Dashboard/color.check.ts b/frontend/src/components/Dashboard/color.check.ts index 202cab9..25ec181 100644 --- a/frontend/src/components/Dashboard/color.check.ts +++ b/frontend/src/components/Dashboard/color.check.ts @@ -19,7 +19,8 @@ import { hsvToRgb, rgbToHsv, type Triple, -} from "./ColorWidget" +} from "./ui/core/color" +import { discToHsv, hsvToDisc } from "./ui/core/disc" /** The primaries, plus the two corners a conversion usually gets wrong. */ const KNOWN: [Triple, Triple, string][] = [ @@ -76,4 +77,48 @@ for (const wrong of [undefined, null, "amber", [1, 2], ["a", "b", "c"]]) { } assert.equal(decodeColor("#nothex", "hex"), null) +// --- the disc: an angle is a hue, a radius is a saturation ---------------- + +assert.equal( + discToHsv(0, 0)[1], + 0, + "the middle of the disc is white, whatever angle it is called", +) +assert.deepEqual( + discToHsv(0, -1), + [0, 100], + "straight up is red, fully saturated", +) +assert.deepEqual( + discToHsv(1, 0), + [90, 100], + "a quarter turn clockwise is 90 degrees", +) +assert.deepEqual( + discToHsv(0, 1), + [180, 100], + "straight down is the opposite hue", +) +assert.deepEqual( + discToHsv(0, 2), + [180, 100], + "a finger past the rim keeps setting the colour it points at", +) + +for (let hue = 0; hue < 360; hue += 15) { + for (const saturation of [25, 60, 100]) { + const { sx, sy } = hsvToDisc(hue, saturation) + const [backHue, backSat] = discToHsv(sx, sy) + // Modulo the wrap: 0 and 360 are the same angle. + const drift = Math.min( + Math.abs(backHue - hue), + 360 - Math.abs(backHue - hue), + ) + assert.ok( + drift <= 1 && Math.abs(backSat - saturation) <= 1, + `${hue}deg ${saturation}% came back as ${backHue}deg ${backSat}%`, + ) + } +} + console.log("colour conversions ok") diff --git a/frontend/src/components/Dashboard/dashboard.css b/frontend/src/components/Dashboard/dashboard.css index c8c4082..af4229d 100644 --- a/frontend/src/components/Dashboard/dashboard.css +++ b/frontend/src/components/Dashboard/dashboard.css @@ -10,7 +10,6 @@ in the pitch its widgets actually snap to (`--dot-x` / `--dot-y`), and the negative offset puts a dot centre on the grid's own origin. */ .dot-canvas { - background-color: var(--background); background-image: radial-gradient( circle at 1px 1px, color-mix(in srgb, var(--muted-foreground) 30%, transparent) 1.5px, @@ -75,91 +74,13 @@ } /* - * A publish in flight, drawn as a ring just inside the tile's own edge. - * - * An overlay rather than anything the widget owns: it takes no layout box and - * no clicks, so a control being used never resizes its tile or moves what sits - * around it. Full opacity at rest, so a panel that asks for no motion still - * gets the ring — the animation only breathes it. - */ -.widget-transmit { - position: absolute; - inset: 0; - pointer-events: none; - border-radius: var(--radius-lg); - box-shadow: inset 0 0 0 2px var(--primary); -} - -/* - * The colour wheel, sized to whatever tile it was put on. - * - * `min(100cqw, 100cqh)` is what keeps one square inside a box of any shape - * without measuring anything in JS — the box is the query container, so the - * wheel reads its height as well as its width. The ring itself is a conic - * gradient set on the element, so nothing here repaints per frame. - */ -.widget-wheel-box { - container-type: size; -} - -.widget-wheel { - width: min(100cqw, 100cqh); - aspect-ratio: 1; -} - -/* The tile is its own query container, so the widget can be laid out by the - shape it was given rather than by the viewport — a wall panel's rows are - often wider than they are tall, and a wheel stacked above two sliders in one - of those is a dot. A container cannot answer a query about itself, which is - what the inner `-body` is for. */ -.widget-color { - container-type: size; -} - -@container (min-aspect-ratio: 3 / 2) { - .widget-color-body { - flex-direction: row; - align-items: center; - } - - /* Square by its height, taken from the tile: the wheel keeps whatever room - the row has and the components take the rest of the width. */ - .widget-color-body > .widget-wheel-box { - flex: none; - width: 100cqh; - } - - .widget-color-levels { - flex: 1; - min-width: 0; - } -} - -/* - * Motion. A value settling is a neutral state change; a selection indicator - * moving is emphasized (Material). `` only - * covers `motion/react`, so CSS asks for itself. + * Motion. A selection indicator moving is emphasized (Material). + * `` only covers `motion/react`, so CSS + * asks for itself. The dashboard's own widgets are drawn by the component + * sets and animate through `motion`; what is left here is the app-side + * segmented shape, which the flow screens and the dashboard editor share. */ @media (prefers-reduced-motion: no-preference) { - /* One beat per second, which reads as "on its way" from across a room - without becoming the loudest thing in a browser tab. */ - .widget-transmit { - animation: widget-transmit var(--duration-pulse) var(--ease-standard) - infinite alternate; - } - - @keyframes widget-transmit { - from { - opacity: 0.2; - } - } - - /* The arc is the full 240 degrees and the dash hides the rest of it, so the - reading changes by animating one number rather than re-pathing. */ - .widget-gauge-arc { - transition: stroke-dashoffset var(--duration-base) var(--ease-standard); - } - /* One indicator that slides between segments, rather than a fill that jumps from cell to cell. */ .widget-segment-thumb { diff --git a/frontend/src/components/Dashboard/palette.check.ts b/frontend/src/components/Dashboard/palette.check.ts index 1202f47..cde7efb 100644 --- a/frontend/src/components/Dashboard/palette.check.ts +++ b/frontend/src/components/Dashboard/palette.check.ts @@ -26,7 +26,6 @@ import assert from "node:assert/strict" import { CHART_SLOTS, NO_PALETTE, - paletteOf, slotsFor, } from "@/components/Common/UplotChart" @@ -63,45 +62,26 @@ for (const lines of [0, -3, 1.5, 9, Number.NaN]) { // --- inertness: a named palette is drawn as written ----------------------- +// A dashboard names colours now (`ui/core/theme.ts`), and a chart draws them +// as they were written: not re-spread, not widened, not reordered. +const NAMED = ["#2a9d8f", "#e9c46a"] + assert.deepEqual( - paletteOf(["3", "1"]), - ["3", "1"], - "the order picked is the order kept", -) -assert.deepEqual( - slotsFor(2, ["3", "1"]), - ["3", "1"], + slotsFor(2, NAMED), + NAMED, "a named palette is never re-spread", ) assert.deepEqual( - slotsFor(5, ["3", "1"]), - ["3", "1"], + slotsFor(5, NAMED), + NAMED, "and it is not widened to fit more lines either — the chart cycles", ) -// Repeats are dropped rather than drawn: two lines on one slot could not be -// told apart, which is the whole reason the picker offers each slot once. -assert.deepEqual(paletteOf(["2", "2", "4"]), ["2", "4"], "a repeat is dropped") -assert.deepEqual(paletteOf([1, 2]), ["1", "2"], "numbers name slots too") -assert.deepEqual(paletteOf([...CHART_SLOTS].reverse()), [ - "5", - "4", - "3", - "2", - "1", -]) - -// Naming none, and every shape a broken one arrives in, all mean the same -// thing: no palette, so the chart spreads itself. -for (const stored of [undefined, null, "", [], ["9", "nonsense"], { 0: "1" }]) { - assert.equal( - paletteOf(stored), - NO_PALETTE, - `${JSON.stringify(stored)} names no palette`, - ) +// Naming none means the same thing however it is stored: the chart spreads. +for (const stored of [undefined, NO_PALETTE]) { for (let lines = 1; lines <= CHART_SLOTS.length; lines++) { assert.deepEqual( - slotsFor(lines, paletteOf(stored)), + slotsFor(lines, stored), SPREAD[lines], `${lines} lines still spread`, ) diff --git a/frontend/src/components/Dashboard/panels.tsx b/frontend/src/components/Dashboard/panels.tsx index 0343182..329c504 100644 --- a/frontend/src/components/Dashboard/panels.tsx +++ b/frontend/src/components/Dashboard/panels.tsx @@ -1,10 +1,9 @@ import { useQuery } from "@tanstack/react-query" -import { Ban, ChevronDown, Plus, X } from "lucide-react" +import { Ban, ChevronDown, Plus, RotateCw, X } from "lucide-react" import { useState } from "react" import type { MessageInfo, SettingDef, WidgetDef } from "@/client" import { DEFAULT_RANGE, RANGES } from "@/components/Common/RangePicker" -import { CHART_SLOTS, paletteOf } from "@/components/Common/UplotChart" import { PANEL_SECTION, PanelTitle, @@ -36,7 +35,6 @@ import { } from "@/components/ui/select" import { Switch } from "@/components/ui/switch" import { cn } from "@/lib/utils" -import { MAX_SEGMENTS, type Segment, segmentsOf } from "./BarWidget" import { MAX_SERIES, refreshFor } from "./ChartWidget" import { COLOR_DTYPES, COLOR_FORMATS, colorFormatOf } from "./ColorWidget" import { @@ -49,12 +47,16 @@ import { import { ICON_COLORS, ICON_NAMES, ICONS } from "./icons" import { messageCatalogQueryOptions } from "./queries" import { + LOOK_CHOICES, + lookOf, SETTING_DTYPES, type SettingName, settingIssue, settingOf, THEME_CHOICES, } from "./settings" +import { type BarRow, MAX_ROWS, rowsOf, showTitle } from "./ui/core/config" +import { parsePalette, roleLabel } from "./ui/core/theme" import { acceptsDtype, INPUT_WIDGETS, @@ -70,6 +72,10 @@ const config = (widget: WidgetDef) => const str = (value: unknown) => (value == null ? "" : String(value)) +/** A number field left blank inherits rather than reading as zero. */ +const numberOrNone = (raw: string) => + raw.trim() === "" ? undefined : Number(raw) + /** Which messages this kind of widget may be pointed at. */ function choicesFor(kind: WidgetKind, catalog: MessageInfo[]): MessageInfo[] { const input = INPUT_WIDGETS.has(kind) @@ -273,14 +279,20 @@ export function WidgetPanel({ const series = seriesOf(widget) const setSeries = (next: Series[]) => set({ series: next }) - // A bar's nested readings. An empty row stands in for none, so an unnested - // bar still offers the picker rather than only a button. - const segments = segmentsOf(widget) - const rows: Segment[] = segments.length ? segments : [{}] - // Always written as a list; `inner_dtype` belonged to the single binding a - // bar carried before it stacked, and goes with it. - const setSegments = (next: Segment[]) => - set({ inner: next, inner_dtype: undefined }) + // A bar's readings. An empty row stands in for none, so a fresh bar offers + // the picker rather than only a button. + const bound = rowsOf(widget) + const rows: BarRow[] = bound.length ? bound : [{}] + /** Writing rows is also what retires the shape a bar was stored in before. */ + const setRows = (next: BarRow[]) => + set({ + rows: next, + message: undefined, + dtype: undefined, + inner: undefined, + inner_dtype: undefined, + inner_label: undefined, + }) // The icon widget's mapping. Position is the row's identity, as with series. const rules = (cfg.rules ?? []) as { at?: unknown @@ -337,6 +349,20 @@ export function WidgetPanel({

) : null} + {/* The title is still the widget's name — what a screen reader calls + its controls, and what a publish is labelled with. This is only + whether the panel draws it: a row of gauges under one heading + reads better without four repeated captions above them. */} +
+ Show title + set({ show_title: value })} + /> +
+ {widget.type === "markdown" ? (
@@ -446,8 +472,9 @@ export function WidgetPanel({
)}
- ) : // A clock reads the wall; a picker would bind a message nothing reads. - widget.type === "clock" ? null : ( + ) : // A clock reads the wall; a picker would bind a message nothing + // reads. A bar binds a row at a time, below. + widget.type === "clock" || widget.type === "bar" ? null : ( {widget.type === "bar" ? ( -
- {rows.map((segment, index) => ( +
+ {rows.map((row, index) => (
-
- - setSegments( +
+
+ + setRows( + rows.map((other, at) => + at === index ? { ...other, message, dtype } : other, + ), + ) + } + /> +
+ + setRows( rows.map((other, at) => - at === index ? { ...other, message, dtype } : other, + at === index + ? { ...other, label: event.target.value } + : other, + ), + ) + } + /> + +
+ {/* Blank inherits the widget's own scale below, which is what + a bar of comparable readings wants. A row that measures + something else — a percentage beside a load in kW — says + so here. */} +
+ + setRows( + rows.map((other, at) => + at === index + ? { + ...other, + min: numberOrNone(event.target.value), + } + : other, + ), + ) + } + /> + + setRows( + rows.map((other, at) => + at === index + ? { + ...other, + max: numberOrNone(event.target.value), + } + : other, + ), + ) + } + /> + + setRows( + rows.map((other, at) => + at === index + ? { + ...other, + unit: event.target.value || undefined, + } + : other, ), ) } />
-
))} - {rows.length < MAX_SEGMENTS ? ( + {rows.length < MAX_ROWS ? ( ) : null}
@@ -1039,9 +1145,101 @@ function SettingBinding({ ) } +/** + * The dashboard's colours, pasted or typed. + * + * A link is the fastest way to a palette somebody already likes, so anything + * with hex in it is read — a coolors.co link, a colorhunt one, a comma list, a + * column of `#rrggbb`. What is kept is the order, because order is the role. + * + * The text is held locally so a half-typed link is not fought over while it is + * being typed; the parsed colours are written through on every keystroke, which + * is what makes pasting a link show the dashboard immediately. + */ +function PalettePicker({ + palette, + onChange, +}: { + palette: string[] + onChange: (palette: string[]) => void +}) { + const [draft, setDraft] = useState(palette.join(" ")) + + const write = (text: string) => { + setDraft(text) + const next = parsePalette(text) + if (next.join(" ") !== palette.join(" ")) onChange(next) + } + + return ( +
+ write(event.target.value)} + /> + {palette.length === 0 ? ( +

+ No palette — this dashboard keeps the app's own colours. +

+ ) : ( +
+ {palette.map((hex, index) => ( + + + + {roleLabel(index)} + + + ))} + {/* The roles are positional, and a palette rarely arrives in the + order a dashboard wants them. Turning it is quicker than + retyping five colours. */} + +
+ )} +
+ ) +} + /** How a setting's own value reads in the "nothing is driving it" line. */ function valueLabel(name: SettingName, value: unknown): string { if (name === "locked") return value === true ? "read-only" : "editable" + if (name === "touch") return value === true ? "touch friendly" : "pointer" + if (name === "look") return lookOf(value) + if (name === "background") return value ? "that image" : "no image" + if (name === "palette") { + const count = parsePalette(value).length + return count ? `those ${count} colours` : "no palette" + } const chosen = THEME_CHOICES.find(([option]) => option === value) return (chosen?.[1] ?? "System").toLowerCase() } @@ -1072,8 +1270,11 @@ export function DashboardPanel({ const canvas = canvasOf(dashboard) const theme = settingOf(dashboard, "theme") const locked = settingOf(dashboard, "locked") + const look = settingOf(dashboard, "look") + const background = settingOf(dashboard, "background") + const touch = settingOf(dashboard, "touch") const paletteSetting = settingOf(dashboard, "palette") - const palette = paletteOf(paletteSetting.value) + const palette = parsePalette(paletteSetting.value) /** Settings are a map, so one of them changing rewrites the whole of it. */ const setSetting = (name: SettingName, setting: SettingDef) => @@ -1205,6 +1406,28 @@ export function DashboardPanel({

+
+ Look + setSetting("look", { ...look, value })} + /> + setSetting("look", setting)} + /> +

+ How this dashboard is drawn. Glass floats translucent tiles over a + soft moving ground; Material lays flat tonal cards on a plain one. + The widgets are the same either way — a look changes what they + look like and nothing about what they do. +

+
+
Theme
Palette -
- {CHART_SLOTS.map((slot) => { - const picked = palette.includes(slot) - return ( -
+ + setSetting("palette", { ...paletteSetting, value }) + } + /> + setSetting("palette", setting)} + />

- The colours this dashboard's charts draw with, taken in the order - you pick them — a chart with more lines than colours starts over - at the first. Pick none and each chart spreads itself across the - range by how many lines it has, which is usually what you want. - Each colour is offered once: two lines sharing one could not be - told apart, and neighbouring ones are close enough already. + The colours this dashboard is drawn in, in order: the ground, the + surface a widget is, the primary, the accent, and the text. Leave + the later ones off and they are worked out from the ones you gave + — three colours are a whole dashboard. Anything past the five is + another colour for a chart to draw a line in. Name none and the + dashboard keeps the app's own. +

+
+ +
+ Background + + setSetting("background", { + ...background, + value: event.target.value, + }) + } + /> + setSetting("background", setting)} + /> +

+ An image drawn under the widgets, covering the canvas. It takes + the place of the ground the Glass look brings with it. Bind a + message and a flow decides the picture — one per season, or one + per time of day. +

+
+ +
+ Touch +
+ Touch friendly + + setSetting("touch", { ...touch, value }) + } + /> +
+ setSetting("touch", setting)} + /> +

+ Bigger controls, and nothing that only happens on hover — for a + panel that is touched rather than pointed at. A phone gets this + anyway; a wall panel has no way to say so for itself.

diff --git a/frontend/src/components/Dashboard/publish.tsx b/frontend/src/components/Dashboard/publish.tsx index 3e06791..db219a1 100644 --- a/frontend/src/components/Dashboard/publish.tsx +++ b/frontend/src/components/Dashboard/publish.tsx @@ -4,9 +4,9 @@ import type { ApiError, WidgetDef } from "@/client" import { useLiveValue } from "@/components/Flow/liveStore" import useCustomToast from "@/hooks/useCustomToast" import { handleError } from "@/utils" -// The transmit overlay's rule lives beside the other widget CSS, and CSS is +// The transmit ring's rule lives with the dashboard's own geometry, and CSS is // chunked per entry — so the sheet is pulled in wherever the pulse is drawn. -import "./dashboard.css" +import "./ui/core/core.css" import { usePublishMessage } from "./queries" import { useLocked } from "./settings" @@ -103,12 +103,12 @@ export function usePublish(widget: WidgetDef, dashboard: string) { ) }, /** - * The in-flight pulse, drawn over the whole tile. + * The in-flight pulse, drawn as a ring around the whole tile. * - * Absolutely positioned and inert, so it neither resizes the widget nor - * moves anything around it. Every control renders it; the frame is what it - * hangs off, which is why it must not be put inside a child that positions - * itself. + * A marker rather than the ring itself: the body a control sits in is a + * query container, which is a containing block for anything absolute + * inside it and clips besides — so the frame draws the ring when it sees + * this, and a control need only say that it is publishing. */ pulse: publish.isPending ? ( diff --git a/frontend/src/components/Dashboard/settings.tsx b/frontend/src/components/Dashboard/settings.tsx index 0500b1f..de496fe 100644 --- a/frontend/src/components/Dashboard/settings.tsx +++ b/frontend/src/components/Dashboard/settings.tsx @@ -1,8 +1,9 @@ import { createContext, useContext } from "react" import type { DashboardDef_Output, SettingDef } from "@/client" -import { NO_PALETTE, paletteOf } from "@/components/Common/UplotChart" +import { NO_PALETTE } from "@/components/Common/UplotChart" import { useLiveValue } from "@/components/Flow/liveStore" +import { isLight, parsePalette, rolesOf } from "./ui/core/theme" /** * The dashboard's own settings channel. @@ -30,13 +31,21 @@ import { useLiveValue } from "@/components/Flow/liveStore" * on the server (`app/flow/dashboards.py`); a name missing from it is a * setting this build does not act on rather than an error. */ -export const SETTING_DTYPES: Record = { +export const SETTING_DTYPES = { theme: "str", locked: "bool", -} + /** `glass` or `material`; anything else is material. */ + look: "str", + /** Hex colours, roles by position — see `ui/core/theme.ts`. */ + palette: "list", + /** The URL of an image drawn under the widgets. */ + background: "str", + /** Bigger controls, for a panel that is touched rather than pointed at. */ + touch: "bool", +} as const satisfies Record /** The settings this build actually wires up. */ -export type SettingName = "theme" | "locked" | "palette" +export type SettingName = keyof typeof SETTING_DTYPES /** What `theme` may be set to. `system` follows whatever the device says. */ export const THEME_CHOICES = [ @@ -61,14 +70,14 @@ export function settingOf( * editor cannot author a document the server would refuse. */ export function settingIssue(name: string, setting: SettingDef): string | null { - const want = SETTING_DTYPES[name] + const want = SETTING_DTYPES[name as SettingName] as string | undefined if (!want || !setting.message || !setting.dtype) return null if (setting.dtype === want) return null return `${setting.message} is a ${setting.dtype}; ${name} is driven by a ${want}.` } /** What the setting is worth now: live if it is bound, stored otherwise. */ -function useSetting( +export function useSetting( dashboard: DashboardDef_Output | undefined, name: SettingName, ): unknown { @@ -79,17 +88,59 @@ function useSetting( return live?.value ?? setting.value } +/** What a dashboard is drawn as. Anything unrecorded is material. */ +export type Look = "glass" | "material" + +/** The looks, as the editor offers them. */ +export const LOOK_CHOICES = [ + ["material", "Material"], + ["glass", "Glass"], +] as const + +export const lookOf = (value: unknown): Look => + value === "glass" ? "glass" : "material" + +/** Which of the two component sets draws this dashboard. */ +export const useDashboardLook = ( + dashboard: DashboardDef_Output | undefined, +): Look => lookOf(useSetting(dashboard, "look")) + +/** The dashboard's own colours, as the hexes a palette named. */ +export const useDashboardPalette = ( + dashboard: DashboardDef_Output | undefined, +): string[] => parsePalette(useSetting(dashboard, "palette")) + +/** An image drawn under the widgets, or `""` for the look's own ground. */ +export function useDashboardBackground( + dashboard: DashboardDef_Output | undefined, +): string { + const value = useSetting(dashboard, "background") + return typeof value === "string" ? value.trim() : "" +} + +/** Whether this dashboard is drawn for a finger rather than a pointer. */ +export const useDashboardTouch = ( + dashboard: DashboardDef_Output | undefined, +): boolean => useSetting(dashboard, "touch") === true + /** * The class that themes a dashboard's own surface, or `""` to follow the app. * * A class rather than the root, because inside the app shell this must not * flip the chrome around it. `.light` and `.dark` both redefine the tokens on * whatever carries them (`index.css`), so either direction works on a subtree. + * + * A palette decides this for itself: its first colour *is* the ground, so + * whether the surface is light or dark is a fact about the palette rather than + * a second setting that could disagree with it. The class still goes on, + * because a chart canvas and the shadcn pieces inside the tile read it. */ export function useDashboardTheme( dashboard: DashboardDef_Output | undefined, ): "" | "light" | "dark" { const value = useSetting(dashboard, "theme") + const roles = rolesOf(useDashboardPalette(dashboard)) + if (roles) return isLight(roles.background) ? "light" : "dark" return value === "dark" || value === "light" ? value : "" } @@ -105,9 +156,11 @@ const PaletteContext = createContext(NO_PALETTE) /** * The data colours everything drawn under it uses. * - * Empty when the dashboard names none, which is not the same as "the ramp": - * a chart with no palette spreads itself across the ramp by how many lines it - * draws (`slotsFor`), and only a named palette overrides that. + * The colours a palette named, in draw order — the primary first, then the + * accent, then whatever it listed after the roles. Empty when the dashboard + * names no palette, which is not the same as "the ramp": a chart with no + * palette spreads itself across the ramp by how many lines it draws + * (`slotsFor`), and only a named palette overrides that. * * Mounted by the view *and* the editor: an editor drawing the automatic * spread while the panel beside it showed the dashboard's own palette would @@ -126,7 +179,7 @@ export function PaletteProvider({ dashboard: DashboardDef_Output | undefined children: React.ReactNode }) { - const palette = paletteOf(useSetting(dashboard, "palette")) + const palette = rolesOf(useDashboardPalette(dashboard))?.series ?? NO_PALETTE return ( {children} diff --git a/frontend/src/components/Dashboard/ui/core/arc.ts b/frontend/src/components/Dashboard/ui/core/arc.ts new file mode 100644 index 0000000..f51f2c2 --- /dev/null +++ b/frontend/src/components/Dashboard/ui/core/arc.ts @@ -0,0 +1,26 @@ +/** + * The dial's geometry. + * + * A 240° arc starting at the lower left, which is the shape a gauge is + * expected to have. Both looks draw the same sweep; only the stroke differs. + */ + +export const GAUGE_SWEEP = 240 +export const GAUGE_START = 150 +const RADIUS = 42 + +const point = (angle: number) => { + const radians = (angle * Math.PI) / 180 + return [50 + RADIUS * Math.cos(radians), 50 + RADIUS * Math.sin(radians)] +} + +/** One arc of the dial, as an SVG path in a `0 0 100 78` box. */ +export function arcPath(from: number, to: number): string { + const [x1, y1] = point(from) + const [x2, y2] = point(to) + const large = Math.abs(to - from) > 180 ? 1 : 0 + return `M ${x1} ${y1} A ${RADIUS} ${RADIUS} 0 ${large} 1 ${x2} ${y2}` +} + +/** The whole track, which the reading is drawn over a fraction of. */ +export const GAUGE_TRACK = arcPath(GAUGE_START, GAUGE_START + GAUGE_SWEEP) diff --git a/frontend/src/components/Dashboard/ui/core/color.ts b/frontend/src/components/Dashboard/ui/core/color.ts new file mode 100644 index 0000000..1d0d7e7 --- /dev/null +++ b/frontend/src/components/Dashboard/ui/core/color.ts @@ -0,0 +1,141 @@ +/** + * A colour, on the wire and on the wheel. + * + * The conversions a colour widget publishes through, kept below every renderer + * so the disc's own maths (`disc.ts`) can use them without either set being in + * the way. Pure: no React, no DOM. + */ +import type { WidgetDef } from "@/client" + +/** Three numbers: a colour in whichever of the two triples is meant. */ +export type Triple = [number, number, number] + +export type ColorFormat = "hsv" | "rgb" | "hex" + +/** + * What each format puts on the wire, by payload type. + * + * Mirrored on the server (`COLOR_DTYPES` in `app/flow/dashboards.py`), which + * refuses a binding the format cannot carry. + */ +export const COLOR_DTYPES: Record = { + hsv: "list", + rgb: "list", + hex: "str", +} + +/** The formats, as the editor offers them. */ +export const COLOR_FORMATS = [ + ["hsv", "HSV"], + ["rgb", "RGB"], + ["hex", "Hex"], +] as const + +/** Which format this widget sends. Anything unrecorded is the default. */ +export function colorFormatOf(widget: WidgetDef): ColorFormat { + const format = widget.config?.format + return format === "rgb" || format === "hex" ? format : "hsv" +} + +/** Rounded into range, for a percentage or a channel. */ +export const clamp = (value: number, high: number) => + Math.min(high, Math.max(0, Math.round(value))) + +/** A hue is an angle: 370 degrees is 10, and -10 is 350. */ +export const wrap = (hue: number) => ((Math.round(hue) % 360) + 360) % 360 + +/** + * HSV to RGB — the same conversion the reference's DMX encoders do, so a + * fixture wired to `rgb` gets what one wired to `hsv` works out for itself. + * + * Hue 0-360 degrees, saturation and value 0-100 percent in; three 0-255 + * channels out. + */ +export function hsvToRgb([hue, saturation, value]: Triple): Triple { + const level = clamp(value, 100) / 100 + const chroma = level * (clamp(saturation, 100) / 100) + const sector = (((hue % 360) + 360) % 360) / 60 + const second = chroma * (1 - Math.abs((sector % 2) - 1)) + const base = level - chroma + const [red, green, blue] = + sector < 1 + ? [chroma, second, 0] + : sector < 2 + ? [second, chroma, 0] + : sector < 3 + ? [0, chroma, second] + : sector < 4 + ? [0, second, chroma] + : sector < 5 + ? [second, 0, chroma] + : [chroma, 0, second] + const channel = (part: number) => Math.round((part + base) * 255) + return [channel(red), channel(green), channel(blue)] +} + +/** The way back, for a colour some flow set rather than this wheel. */ +export function rgbToHsv(rgb: Triple): Triple { + const [red, green, blue] = rgb.map((channel) => clamp(channel, 255) / 255) + const high = Math.max(red, green, blue) + const spread = high - Math.min(red, green, blue) + let hue = 0 + if (spread) { + hue = + high === red + ? ((green - blue) / spread) % 6 + : high === green + ? (blue - red) / spread + 2 + : (red - green) / spread + 4 + hue = (hue * 60 + 360) % 360 + } + return [ + Math.round(hue), + Math.round(high ? (spread / high) * 100 : 0), + Math.round(high * 100), + ] +} + +const toHex = (rgb: Triple) => + `#${rgb.map((channel) => clamp(channel, 255).toString(16).padStart(2, "0")).join("")}` + +const fromHex = (text: string): Triple | null => { + const digits = /^#?([0-9a-f]{6})$/i.exec(text)?.[1] + if (!digits) return null + const at = (index: number) => + Number.parseInt(digits.slice(index * 2, index * 2 + 2), 16) + return [at(0), at(1), at(2)] +} + +/** What this control publishes, in the format its config picked. */ +export function encodeColor(hsv: Triple, format: ColorFormat): unknown { + if (format === "hsv") return hsv + const rgb = hsvToRgb(hsv) + return format === "rgb" ? rgb : toHex(rgb) +} + +/** + * What came back over the socket, as the wheel's own three numbers. + * + * Null for anything that is not a colour in this format — nothing published + * yet, or a flow that answered with something else. + */ +export function decodeColor( + value: unknown, + format: ColorFormat, +): Triple | null { + if (format === "hex") { + const rgb = typeof value === "string" ? fromHex(value) : null + return rgb && rgbToHsv(rgb) + } + if (!Array.isArray(value) || value.length < 3) return null + const [first, second, third] = value.slice(0, 3).map(Number) + if (![first, second, third].every(Number.isFinite)) return null + if (format === "rgb") return rgbToHsv([first, second, third]) + return [wrap(first), clamp(second, 100), clamp(third, 100)] +} + +/** The colour a set of three makes, for a swatch or a handle. */ +export const cssOf = (hsv: Triple) => `rgb(${hsvToRgb(hsv).join(" ")})` + +/** Nothing published yet: white at full brightness, which is a lamp that is on. */ +export const UNSET: Triple = [0, 0, 100] diff --git a/frontend/src/components/Dashboard/ui/core/config.check.ts b/frontend/src/components/Dashboard/ui/core/config.check.ts new file mode 100644 index 0000000..05bf787 --- /dev/null +++ b/frontend/src/components/Dashboard/ui/core/config.check.ts @@ -0,0 +1,96 @@ +/** + * Reading a widget's document, checked. + * + * cd frontend && bun run src/components/Dashboard/ui/core/config.check.ts + * + * What matters here is that a bar written before rows existed still draws the + * same picture: the shape on disk changed, and no stored dashboard may lose a + * reading because of it. + */ + +import assert from "node:assert/strict" + +import type { WidgetDef } from "@/client" +import { format, fractionOf, MAX_ROWS, rowsOf, showTitle } from "./config" + +const bar = (config: Record): WidgetDef => + ({ id: "b", type: "bar", config }) as WidgetDef + +// --- a bar's readings, in every shape a document carries them ------------- + +assert.deepEqual( + rowsOf(bar({ rows: [{ message: "a.x", dtype: "float", label: "A" }] })), + [{ message: "a.x", dtype: "float", label: "A" }], + "rows are read as written", +) + +assert.deepEqual( + rowsOf( + bar({ + message: "a.load", + dtype: "float", + inner: "a.pv", + inner_dtype: "float", + inner_label: "Roof", + }), + ), + [ + { message: "a.load", dtype: "float" }, + { message: "a.pv", dtype: "float", label: "Roof" }, + ], + "a bar with one nested reading becomes two rows", +) + +assert.equal( + rowsOf( + bar({ + message: "a.load", + dtype: "float", + inner: [ + { message: "a.pv", dtype: "float" }, + { message: "a.grid", dtype: "float" }, + ], + }), + ).length, + 3, + "a stacked bar becomes the outer reading and its segments", +) + +assert.equal( + rowsOf(bar({ rows: Array.from({ length: 9 }, () => ({ message: "a.x" })) })) + .length, + MAX_ROWS, + "a bar draws at most eight readings", +) + +assert.deepEqual(rowsOf(bar({})), [], "an unbound bar has no rows") + +// --- the rest ------------------------------------------------------------- + +assert.equal( + showTitle(bar({})), + true, + "a widget shows its title unless told not to", +) +assert.equal(showTitle(bar({ show_title: false })), false) +assert.equal(showTitle(bar({ show_title: true })), true) + +assert.equal(fractionOf(5, 0, 10), 0.5) +assert.equal( + fractionOf(-1, 0, 10), + 0, + "a reading under the scale sits at its foot", +) +assert.equal(fractionOf(11, 0, 10), 1, "and one over it fills the track") +assert.equal(fractionOf(null, 0, 10), 0) +assert.equal( + fractionOf(5, 0, 0), + 1, + "a scale of no width fills rather than dividing by zero", +) + +assert.equal(format(1.234, 1), "1.2") +assert.equal(format(true, null), "On") +assert.equal(format(null, 1), "—") + +console.log("config: ok") diff --git a/frontend/src/components/Dashboard/ui/core/config.ts b/frontend/src/components/Dashboard/ui/core/config.ts new file mode 100644 index 0000000..e2a8949 --- /dev/null +++ b/frontend/src/components/Dashboard/ui/core/config.ts @@ -0,0 +1,113 @@ +/** + * Reading a widget's document. + * + * The four helpers at the top used to be copied into every widget file: + * `widgets.tsx` renders the others, so none of them could import it back + * without closing the circle. A module below all of them costs one import and + * ends the duplication — nothing in `ui/` may import a widget. + */ +import type { WidgetDef } from "@/client" + +export const config = (widget: WidgetDef): Record => + (widget.config ?? {}) as Record + +export const text = (value: unknown, fallback = ""): string => + value === null || value === undefined ? fallback : String(value) + +export const num = (value: unknown, fallback: number): number => { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : fallback +} + +/** Formats a reading the way a panel across the room should read it. */ +export function format(value: unknown, precision: number | null): string { + if (value === null || value === undefined) return "—" + if (typeof value === "boolean") return value ? "On" : "Off" + if (typeof value === "number") { + return precision === null ? String(value) : value.toFixed(precision) + } + if (typeof value === "object") return JSON.stringify(value) + return String(value) +} + +/** Where a reading sits on its scale, as 0..1. */ +export const fractionOf = ( + value: number | null, + min: number, + max: number, +): number => + value === null + ? 0 + : Math.min(1, Math.max(0, (value - min) / (max - min || 1))) + +/** + * Whether the frame draws this widget's title. + * + * Absent means yes: a document written before the switch existed keeps the + * header it has. The title is still the widget's accessible name and its + * publish label either way — hiding it is a matter of what the panel shows, + * not of what the widget is called. + */ +export const showTitle = (widget: WidgetDef): boolean => + config(widget).show_title !== false + +/** Radix hands back a string; the message wants whatever was configured. */ +export function asOriginal( + selected: string, + options: { value?: unknown }[], +): unknown { + const match = options.find((option) => text(option.value) === selected) + return match ? match.value : selected +} + +/** + * How many readings one bar draws, and a hard ceiling. + * + * Mirrored server-side as `BAR_ROWS` (`app/flow/dashboards.py`). The old cap of + * three was a contrast limit: every nested segment was drawn in the one token + * that cleared 3:1 against the outer fill, so a fourth could not be told from + * its neighbour. Rows are separate tracks in the dashboard's own data colours, + * so the limit is now legibility of the stack itself. + */ +export const MAX_ROWS = 8 + +/** One reading a bar draws, as the document stores it. */ +export type BarRow = { + message?: string + dtype?: string + label?: string + /** Blank inherits the widget's own scale. */ + min?: number + max?: number + unit?: string +} + +/** + * The readings a bar draws, in every shape a document may carry them. + * + * Current documents write `rows`. Before that a bar had one reading with up to + * three nested inside it (`inner`, itself written either as one name or as a + * list), which is read here as the outer reading followed by the nested ones — + * the same picture, drawn as separate tracks. The editor writes `rows` on the + * first save and drops the old keys. + */ +export function rowsOf(widget: WidgetDef): BarRow[] { + const cfg = config(widget) + if (Array.isArray(cfg.rows)) return (cfg.rows as BarRow[]).slice(0, MAX_ROWS) + + const outer: BarRow[] = cfg.message + ? [{ message: text(cfg.message), dtype: text(cfg.dtype) }] + : [] + const inner: BarRow[] = Array.isArray(cfg.inner) + ? (cfg.inner as BarRow[]) + : cfg.inner + ? [ + { + message: text(cfg.inner), + dtype: text(cfg.inner_dtype), + label: text(cfg.inner_label) || undefined, + }, + ] + : [] + return [...outer, ...inner].slice(0, MAX_ROWS) +} diff --git a/frontend/src/components/Dashboard/ui/core/contract.ts b/frontend/src/components/Dashboard/ui/core/contract.ts new file mode 100644 index 0000000..cc9a594 --- /dev/null +++ b/frontend/src/components/Dashboard/ui/core/contract.ts @@ -0,0 +1,170 @@ +/** + * What a look has to draw. + * + * There are two component sets — `ui/glass` and `ui/material` — and this is + * the whole of what they have in common. Each implements every entry below; + * a widget asks for the set with `useUi()` and never learns which one it got. + * + * The split is deliberate: **behaviour lives under `ui/core`** (the hooks in + * `controls.ts`, `values.ts` and `disc.ts` hold the state, the keyboard and + * every `aria-`), and a renderer's only job is markup and motion. That is what + * makes the two sets the same dashboard: a control cannot behave differently + * in one look, because neither set implements the behaviour. + */ +import type { LinkProps } from "@tanstack/react-router" + +import type { Triple } from "./color" + +/** Testids the panels' tests pin. Both sets emit them, from these constants. */ +export const TESTID = { + frame: "widget-frame", + issue: "widget-issue", + /** A class, not an id: the editor drags a widget by whatever carries it. */ + grip: "widget-grip", + barRow: "bar-row", + barFill: "bar-fill", + disc: "color-wheel", + swatch: "color-swatch", + rail: "panel-rail", + locked: "dashboard-locked", +} as const + +export type FrameProps = { + /** Absent draws no header at all — see `showTitle`. */ + title?: string + /** Mis-wired: the same red dot and tooltip a failing node carries. */ + issue?: string | null + /** Make this widget draggable in the editor. */ + grip?: boolean + selected?: boolean + onClick?: React.MouseEventHandler + children: React.ReactNode +} + +export type ButtonProps = { + variant?: "filled" | "tonal" | "text" + pressed?: boolean + disabled?: boolean + label?: string + onClick: () => void + children: React.ReactNode +} + +export type SwitchProps = { + checked: boolean + disabled?: boolean + label: string + onChange: (on: boolean) => void +} + +export type SliderProps = { + value: number + min: number + max: number + step: number + label: string + unit?: string + disabled?: boolean + orientation?: "horizontal" | "vertical" + /** The scale under the track. Horizontal only; a column has no room. */ + ticks?: boolean + /** Only the release publishes: a drag would send a value per pixel. */ + onCommit: (value: number) => void +} + +export type SegmentedProps = { + value: string + options: readonly (readonly [string, string])[] + label: string + orientation?: "horizontal" | "vertical" + disabled?: boolean + testId?: string + onChange: (value: string) => void +} + +export type SelectProps = { + value: string + options: { label?: string; value?: unknown }[] + label: string + disabled?: boolean + onChange: (value: unknown) => void +} + +export type InputProps = { + value: string + type: "text" | "number" + label: string + disabled?: boolean + onChange: (value: string) => void + onCommit: () => void +} + +export type ReadoutProps = { + value: unknown + precision: number | null + unit?: string + /** `hero` is the one number a tile is for; `inline` sits in a row. */ + size?: "hero" | "inline" +} + +export type GaugeProps = { + value: number | null + min: number + max: number + precision: number | null + unit?: string + label: string +} + +/** One reading a bar draws, ready to be drawn. */ +export type BarReading = { + label: string + value: number | null + fraction: number + precision: number | null + unit?: string + /** A colour, or a slot of the app's ramp — whatever the palette named. */ + color: string +} + +export type BarProps = { label: string; rows: BarReading[] } + +export type ColorDiskProps = { + name: string + hsv: Triple + disabled?: boolean + onChange: (hsv: Triple) => void + onCommit: () => void +} + +export type RailProps = { + entries: { + name: string + label: string + icon?: string + active: boolean + link: LinkProps + }[] +} + +export type NoticeProps = { children: React.ReactNode } + +/** The ambient ground under the widgets, or the image that replaces it. */ +export type BackdropProps = { image: string } + +export type ComponentSet = { + Backdrop: (props: BackdropProps) => React.ReactNode + Frame: (props: FrameProps) => React.ReactNode + Button: (props: ButtonProps) => React.ReactNode + Switch: (props: SwitchProps) => React.ReactNode + Slider: (props: SliderProps) => React.ReactNode + Segmented: (props: SegmentedProps) => React.ReactNode + Select: (props: SelectProps) => React.ReactNode + Input: (props: InputProps) => React.ReactNode + Readout: (props: ReadoutProps) => React.ReactNode + Gauge: (props: GaugeProps) => React.ReactNode + Bar: (props: BarProps) => React.ReactNode + ColorDisk: (props: ColorDiskProps) => React.ReactNode + Rail: (props: RailProps) => React.ReactNode + Notice: (props: NoticeProps) => React.ReactNode +} diff --git a/frontend/src/components/Dashboard/ui/core/controls.ts b/frontend/src/components/Dashboard/ui/core/controls.ts new file mode 100644 index 0000000..66dc2b8 --- /dev/null +++ b/frontend/src/components/Dashboard/ui/core/controls.ts @@ -0,0 +1,222 @@ +/** + * What the controls do, with nothing about how they look. + * + * Both component sets call these, which is what makes a switch a switch in + * either look: the state, the keyboard and every `aria-` live here, and a + * renderer only decides what it looks like while doing it. + */ +import { useCallback, useId, useRef, useState } from "react" + +import { fractionOf } from "./config" + +// --------------------------------------------------------------------------- +// Press +// --------------------------------------------------------------------------- + +/** A press, as the looks draw it: a ripple for one, a moving glow for the other. */ +export type Press = { id: number; x: number; y: number } + +/** + * Where a control was last pressed, in percent of its own box. + * + * Written onto the element as `--press-x` / `--press-y` for whatever the look + * paints from them, and kept as a short list so a set that draws one ripple + * per press (Material) can. A ripple removes itself when it finishes. + */ +export function usePress() { + const [presses, setPresses] = useState([]) + const next = useRef(0) + + const onPointerDown = useCallback( + (event: React.PointerEvent) => { + const box = event.currentTarget.getBoundingClientRect() + const x = box.width ? ((event.clientX - box.left) / box.width) * 100 : 50 + const y = box.height ? ((event.clientY - box.top) / box.height) * 100 : 50 + event.currentTarget.style.setProperty("--press-x", `${x}%`) + event.currentTarget.style.setProperty("--press-y", `${y}%`) + next.current += 1 + const id = next.current + setPresses((current) => [...current, { id, x, y }]) + }, + [], + ) + + const done = useCallback( + (id: number) => setPresses((current) => current.filter((p) => p.id !== id)), + [], + ) + + return { presses, onPointerDown, done } +} + +// --------------------------------------------------------------------------- +// Switch +// --------------------------------------------------------------------------- + +/** + * A latch. + * + * A `button` rather than a checkbox or a library primitive: `role="switch"` + * plus `aria-checked` is the whole contract, and a button already answers + * Space and Enter. + */ +export function useSwitch({ + checked, + disabled, + label, + onChange, +}: { + checked: boolean + disabled?: boolean + label: string + onChange: (on: boolean) => void +}) { + return { + buttonProps: { + type: "button" as const, + role: "switch", + "aria-checked": checked, + "aria-label": label, + "data-state": checked ? "on" : "off", + disabled, + onClick: () => onChange(!checked), + }, + } +} + +// --------------------------------------------------------------------------- +// Segmented +// --------------------------------------------------------------------------- + +/** One of N, every choice shown at once. */ +export function useSegmented({ + value, + options, + label, + orientation = "horizontal", + disabled, + onChange, +}: { + value: string + options: readonly (readonly [string, string])[] + label: string + orientation?: "horizontal" | "vertical" + disabled?: boolean + onChange: (value: string) => void +}) { + const chosen = options.findIndex((option) => option[0] === value) + return { + chosen, + /** One per instance, so two segmented controls do not share a thumb. */ + thumbId: useId(), + groupProps: { + role: "group", + "aria-label": label, + "data-orientation": orientation, + }, + itemProps: (index: number) => ({ + type: "button" as const, + "aria-pressed": index === chosen, + "data-active": index === chosen ? "" : undefined, + disabled, + onClick: () => onChange(options[index][0]), + }), + } +} + +// --------------------------------------------------------------------------- +// Slider +// --------------------------------------------------------------------------- + +/** + * How many intervals the scale under a slider is cut into. + * + * A mark lands on a step wherever the range divides evenly, so a value aimed + * at is one the slider can stop on. Five labels is what stays readable across a + * room; a range of five steps or fewer is simply labelled in full. + */ +export function tickIntervals(steps: number): number { + if (!Number.isFinite(steps) || steps <= 0) return 4 + if (steps <= 5) return Math.max(1, Math.round(steps)) + return [4, 3, 2].find((count) => Number.isInteger(steps / count)) ?? 4 +} + +/** + * A value set by dragging, published when the handle is let go. + * + * A drag would otherwise send a value per pixel and flood whatever is + * listening, so the draft follows the finger and only the release publishes. + * While there is no draft the handle follows the engine, which is what makes a + * value set elsewhere show up here. + */ +export function useSliderDrag({ + value, + min, + max, + step, + label, + unit, + disabled, + orientation = "horizontal", + ticks = false, + onCommit, +}: { + value: number + min: number + max: number + step: number + label: string + unit?: string + disabled?: boolean + orientation?: "horizontal" | "vertical" + ticks?: boolean + onCommit: (value: number) => void +}) { + const [draft, setDraft] = useState(null) + const current = draft ?? value + + const release = () => { + if (draft === null) return + onCommit(draft) + setDraft(null) + } + + const span = max - min + const intervals = tickIntervals(step > 0 ? span / step : 0) + // Taken off the step, so 0–1 at 0.01 reads "0.25" and 0–100 at 1 reads "25" + // without a precision setting of its own. + const digits = (String(step).split(".")[1] ?? "").length + + return { + current, + fraction: fractionOf(current, min, max), + inputProps: { + type: "range" as const, + min, + max, + step, + value: current, + disabled, + "aria-label": label, + "aria-orientation": orientation, + "aria-valuetext": unit ? `${current}${unit}` : undefined, + onChange: (event: React.ChangeEvent) => + setDraft(Number(event.target.value)), + onPointerUp: release, + onKeyUp: release, + onBlur: release, + }, + /** The scale under the track, drawn rather than declared: no browser + * renders `