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 6b77675167
commit 5b9628c02e
53 changed files with 5559 additions and 1594 deletions
+42 -26
View File
@@ -147,10 +147,16 @@ def process(tick, mode="comfort", away=False, setpoint=21.0):
"outdoor": round(outdoor, 2),
"humidity": round(humidity, 1),
# Not a reading: what the panel on the wall should wear. The dashboard
# binds its `theme` setting to this, which is what "switch the panel at
# sunset" looks like here — a flow publishing, not a rule in the
# dashboard.
"panel_theme": "dark" if hour < 7.0 or hour >= 20.0 else "light",
# binds its `palette` setting to this, which is what "switch the panel
# at sunset" looks like here — a flow publishing, not a rule in the
# dashboard. Colours in role order: the ground, the surface a widget
# is, the primary and the accent. The ground is also what decides
# whether the panel reads light or dark, so one message does both.
"panel_palette": (
["#101b24", "#17252f", "#7ba3b8", "#e5a184"]
if hour < 7.0 or hour >= 20.0
else ["#f2f0ea", "#ffffff", "#4a7189", "#de8f6e"]
),
}
'''
@@ -337,7 +343,7 @@ HOUSE_NODES = [
{"name": "outdoor", "dtype": "float", "interval": 60},
{"name": "humidity", "dtype": "float"},
# Twice a day at most, so it is paced rather than sent every tick.
{"name": "panel_theme", "dtype": "str", "interval": 60},
{"name": "panel_palette", "dtype": "list", "interval": 60},
],
},
{
@@ -870,14 +876,22 @@ NOW_WIDGETS = [
"title": "House draw, and the share the roof covered",
"layout": at(11, 2, 5, 2),
"config": {
"message": msg(HOUSE, "load_kw"),
"dtype": "float",
# A second reading nested inside the first, on the same scale, so
# containment is what the picture shows rather than something to
# work out. It only means anything because the flow computed a
# share: the roof's whole output is not part of the house's draw.
"inner": msg(HOUSE, "self_use_kw"),
"inner_dtype": "float",
# Two readings on one scale, a row each: the shorter bar *is* the
# smaller part, so containment is still what the picture shows.
# It only means anything because the flow computed a share — the
# roof's whole output is not part of the house's draw.
"rows": [
{
"message": msg(HOUSE, "load_kw"),
"dtype": "float",
"label": "House",
},
{
"message": msg(HOUSE, "self_use_kw"),
"dtype": "float",
"label": "From the roof",
},
],
"min": 0,
"max": 9,
"precision": 2,
@@ -1226,21 +1240,23 @@ def seed_dashboard(api: Api) -> None:
"canvas_height": CANVAS[1],
# The settings channel: the dashboard itself as a receiver. Costs
# no tile, which is why it fits a panel with all 17 rows spoken
# for. `system` is the fallback until the flow first says
# otherwise; `locked` is left unset, which is the static default
# and keeps every control on the demo usable.
# for. `locked` is left unset, which is the static default and
# keeps every control on the demo usable.
"settings": {
"theme": {
"value": "system",
"message": msg(HOUSE, "panel_theme"),
"dtype": "str",
# Glass, because the demo is the thing people look at first
# and this is the look that has to be seen to be understood.
"look": {"value": "glass"},
# Driven by the house flow: the panel's colours follow the
# hour, and the ground it names is also what makes it read
# light by day and dark at night. The value here is the
# fallback until the flow first says otherwise.
"palette": {
"value": ["#f2f0ea", "#ffffff", "#4a7189", "#de8f6e"],
"message": msg(HOUSE, "panel_palette"),
"dtype": "list",
},
# The same three slots a three-line chart would spread onto
# by itself, in the opposite order — so the Power chart reads
# just as clearly while the panel shows a palette that was
# chosen rather than one that was defaulted into. Naming none
# here would demonstrate nothing.
"palette": {"value": ["5", "3", "1"]},
# A hallway panel is touched, not pointed at.
"touch": {"value": True},
},
"pages": PAGES,
},