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:
2026-08-23 21:52:14 +02:00
parent d4c5af4d5a
commit 0b5ce4fcbb
52 changed files with 5517 additions and 1568 deletions
+59 -21
View File
@@ -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",