Rework the dashboard into two looks over one behaviour
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.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user