Stack a bar's readings, and stop widgets taking the phone sideways

A bar drew its nested reading on top of the outer one in --chart-5, which
measures 2.53:1 against --primary and lost the 3:1 guideline for non-text. The
readings now partition the fill end to end, up to three of them, in a token of
their own: --primary-nested, the primary hue a few steps deeper, 3.14:1 light
and 3.12:1 dark. It cannot also clear 3:1 against --muted — in dark those two
are 5.82:1 apart and a colour 3:1 from both would need a 9:1 gap — so a segment
is drawn inside a gutter of outer fill rather than ever bordering the track,
which is what separates neighbours too, and what caps the count at three. A
nested value larger than its outer used to spill onto the track; it is clamped.
`inner` still reads as a single binding, so no dashboard needs migrating.

On a phone, .widget-grid took its width from the widest thing any widget held —
a truncating flex item still offers its whole unwrapped line as a min-content
contribution — and a handful of widgets had no floor of their own: the uPlot
legend is a table, a fieldset carries min-inline-size: min-content from the UA
sheet, and buttons are whitespace-nowrap. Each is capped now. A widget's body
scrolls rather than clipping, so long text stops painting over the title.

Gauges and bars move between readings instead of jumping, and a segmented
control slides one thumb rather than recolouring cells. The gauge arc is drawn
whole and revealed by its dash, because `d` cannot be transitioned.

UplotChart pushed new readings only when the point count changed, so once a
rolling window was full a refetch left the old values on screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
This commit is contained in:
2026-08-21 10:11:44 +02:00
co-authored by Claude Opus 5
parent d375ea97cc
commit fe1ba46d4e
10 changed files with 589 additions and 85 deletions
+35 -3
View File
@@ -30,6 +30,12 @@ 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
#: Resolved out here on purpose: the store has a ``list`` method, which
#: shadows the builtin for any annotation written inside the class.
Bindings = list[dict[str, Any]]
@@ -119,6 +125,23 @@ class WidgetDef(BaseModel):
"""A chart that asks a flow for its series instead of reading the ring."""
return self.type == "chart" and self.config.get("source") == "query"
@property
def inner_bindings(self) -> Bindings:
"""A bar's nested readings, in either shape a document may 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.
"""
inner = self.config.get("inner")
if isinstance(inner, list):
return [s for s in inner[:BAR_SEGMENTS] 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 messages(self) -> list[str]:
"""Every message name this widget reads."""
@@ -132,8 +155,9 @@ class WidgetDef(BaseModel):
if series.get("message")
]
name = self.config.get("message")
inner = self.config.get("inner") # only a bar nests a second reading
return [str(value) for value in (name, inner) if value]
# 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]
@property
def target(self) -> str:
@@ -170,7 +194,10 @@ class WidgetDef(BaseModel):
str(series.get("dtype") or "")
for series in self.config.get("series") or []
]
return [str(self.config.get(key) or "") for key in ("dtype", "inner_dtype")]
return [
str(self.config.get("dtype") or ""),
*(str(s.get("dtype") or "") for s in self.inner_bindings),
]
@model_validator(mode="after")
def _check_binding(self) -> WidgetDef:
@@ -184,6 +211,10 @@ 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")
allowed = WIDGET_DTYPES.get(self.type)
if not allowed:
return self
@@ -510,6 +541,7 @@ def default_dashboard(name: str) -> DashboardDef:
__all__ = [
"BAR_SEGMENTS",
"DASHBOARD_DIR",
"HISTORY_CAP",
"INPUT_WIDGETS",