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:
@@ -30,6 +30,12 @@ DASHBOARD_DIR = "_dashboards"
|
|||||||
#: A chart cannot ask for an unbounded series; this is the ceiling.
|
#: A chart cannot ask for an unbounded series; this is the ceiling.
|
||||||
HISTORY_CAP = 5000
|
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
|
#: Resolved out here on purpose: the store has a ``list`` method, which
|
||||||
#: shadows the builtin for any annotation written inside the class.
|
#: shadows the builtin for any annotation written inside the class.
|
||||||
Bindings = list[dict[str, Any]]
|
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."""
|
"""A chart that asks a flow for its series instead of reading the ring."""
|
||||||
return self.type == "chart" and self.config.get("source") == "query"
|
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
|
@property
|
||||||
def messages(self) -> list[str]:
|
def messages(self) -> list[str]:
|
||||||
"""Every message name this widget reads."""
|
"""Every message name this widget reads."""
|
||||||
@@ -132,8 +155,9 @@ class WidgetDef(BaseModel):
|
|||||||
if series.get("message")
|
if series.get("message")
|
||||||
]
|
]
|
||||||
name = self.config.get("message")
|
name = self.config.get("message")
|
||||||
inner = self.config.get("inner") # only a bar nests a second reading
|
# Only a bar nests further readings inside the one it draws.
|
||||||
return [str(value) for value in (name, inner) if value]
|
nested = [s.get("message") for s in self.inner_bindings]
|
||||||
|
return [str(value) for value in (name, *nested) if value]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def target(self) -> str:
|
def target(self) -> str:
|
||||||
@@ -170,7 +194,10 @@ class WidgetDef(BaseModel):
|
|||||||
str(series.get("dtype") or "")
|
str(series.get("dtype") or "")
|
||||||
for series in self.config.get("series") 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")
|
@model_validator(mode="after")
|
||||||
def _check_binding(self) -> WidgetDef:
|
def _check_binding(self) -> WidgetDef:
|
||||||
@@ -184,6 +211,10 @@ class WidgetDef(BaseModel):
|
|||||||
)
|
)
|
||||||
return self
|
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)
|
allowed = WIDGET_DTYPES.get(self.type)
|
||||||
if not allowed:
|
if not allowed:
|
||||||
return self
|
return self
|
||||||
@@ -510,6 +541,7 @@ def default_dashboard(name: str) -> DashboardDef:
|
|||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"BAR_SEGMENTS",
|
||||||
"DASHBOARD_DIR",
|
"DASHBOARD_DIR",
|
||||||
"HISTORY_CAP",
|
"HISTORY_CAP",
|
||||||
"INPUT_WIDGETS",
|
"INPUT_WIDGETS",
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
// The segmented shape's thumb transition lives beside the dashboard's own
|
||||||
|
// widgets, and CSS is chunked per entry — so the rule is pulled in wherever
|
||||||
|
// this picker is used, or the two copies of one shape would move differently.
|
||||||
|
import "@/components/Dashboard/dashboard.css"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,22 +52,41 @@ export function RangePicker({
|
|||||||
value: Range
|
value: Range
|
||||||
onChange: (range: Range) => void
|
onChange: (range: Range) => void
|
||||||
}) {
|
}) {
|
||||||
|
const chosen = RANGES.findIndex((range) => range.hours === value.hours)
|
||||||
return (
|
return (
|
||||||
|
// A `fieldset` carries `min-inline-size: min-content` from the UA sheet,
|
||||||
|
// which no width utility overrides. Equal tracks and no gap put the
|
||||||
|
// sliding thumb at its share of the padded box without measuring — a grid
|
||||||
|
// rather than a flex row because `flex-1` under `w-fit` sizes the segments
|
||||||
|
// to a share of the widest label instead of to the label itself.
|
||||||
<fieldset
|
<fieldset
|
||||||
data-testid="range-picker"
|
data-testid="range-picker"
|
||||||
className="flex w-fit items-center gap-1 rounded-full border border-border p-1"
|
className="relative grid w-fit min-w-0 items-center rounded-full border border-border p-1"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `repeat(${RANGES.length}, minmax(0, 1fr))`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<legend className="sr-only">Time range</legend>
|
<legend className="sr-only">Time range</legend>
|
||||||
{RANGES.map((range) => (
|
{chosen >= 0 ? (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="widget-segment-thumb pointer-events-none absolute inset-y-1 rounded-full bg-accent"
|
||||||
|
style={{
|
||||||
|
left: `calc(0.25rem + ${chosen} * (100% - 0.5rem) / ${RANGES.length})`,
|
||||||
|
width: `calc((100% - 0.5rem) / ${RANGES.length})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{RANGES.map((range, index) => (
|
||||||
<button
|
<button
|
||||||
key={range.label}
|
key={range.label}
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={range.hours === value.hours}
|
aria-pressed={index === chosen}
|
||||||
onClick={() => onChange(range)}
|
onClick={() => onChange(range)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full px-2.5 py-1 text-xs transition-colors",
|
"relative z-10 min-w-0 rounded-full px-2.5 py-1 text-xs transition-colors",
|
||||||
range.hours === value.hours
|
index === chosen
|
||||||
? "bg-accent text-accent-foreground"
|
? "text-accent-foreground"
|
||||||
: "text-muted-foreground hover:bg-accent/50",
|
: "text-muted-foreground hover:bg-accent/50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -239,11 +239,16 @@ export function UplotChart({
|
|||||||
}
|
}
|
||||||
}, [key, ready])
|
}, [key, ready])
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: rebuilding the joined table is what the point count stands for.
|
// Every render, deliberately. `plots` is rebuilt on every render in both
|
||||||
|
// consumers, so no memo can hit, and any signature short of hashing shares
|
||||||
|
// the blind spot a point count has: once a rolling window is full, a refetch
|
||||||
|
// carrying different readings leaves the count where it was and never fires.
|
||||||
|
// Safe to run this often because `setData` is idempotent and re-ranges the
|
||||||
|
// scales *from the data* — the opposite of the `redraw(false)` below.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chart.current || plots.length === 0) return
|
if (!chart.current || plots.length === 0) return
|
||||||
chart.current.setData(table(plots))
|
chart.current.setData(table(plots))
|
||||||
}, [points, key])
|
})
|
||||||
|
|
||||||
// The canvas cannot follow a CSS variable, so a theme swap is a redraw. The
|
// The canvas cannot follow a CSS variable, so a theme swap is a redraw. The
|
||||||
// paths are geometry and stay as they are — and leaving them alone is what
|
// paths are geometry and stay as they are — and leaving them alone is what
|
||||||
@@ -272,8 +277,10 @@ export function UplotChart({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{/* Kept at the legend's resting height, so the plot does not resize
|
{/* Kept at the legend's resting height, so the plot does not resize
|
||||||
under the pointer the first time a reading arrives. */}
|
under the pointer the first time a reading arrives. uPlot mounts a
|
||||||
<div ref={legend} className="min-h-6 shrink-0" />
|
table here and a table cannot lay out below its min-content width, so
|
||||||
|
the labels get their own scroller rather than widening the card. */}
|
||||||
|
<div ref={legend} className="min-h-6 min-w-0 shrink-0 overflow-x-auto" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,22 +18,61 @@ const num = (value: unknown, fallback: number) => {
|
|||||||
/** Below this the reading no longer fits on the fill and moves off its end. */
|
/** Below this the reading no longer fits on the fill and moves off its end. */
|
||||||
const FITS = 0.3
|
const FITS = 0.3
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many readings a bar nests, and a hard ceiling.
|
||||||
|
*
|
||||||
|
* 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 }
|
||||||
|
|
||||||
|
/** 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) }]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A level, drawn as a horizontal bar with its reading written on it.
|
* A level, drawn as a horizontal bar with its reading written on it.
|
||||||
*
|
*
|
||||||
* A second message can be nested inside the first — the PV share of an
|
* Further messages can be nested inside the first — the PV share of an
|
||||||
* inverter's input — and is drawn on top of the fill on the same scale, so
|
* inverter's input — and are drawn on the fill on the same scale, stacked end
|
||||||
* containment is what the picture shows rather than something to work out.
|
* 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.
|
||||||
*/
|
*/
|
||||||
export function BarWidget({ widget }: WidgetProps) {
|
export function BarWidget({ widget }: WidgetProps) {
|
||||||
const cfg = config(widget)
|
const cfg = config(widget)
|
||||||
const message = text(cfg.message)
|
const message = text(cfg.message)
|
||||||
const nested = text(cfg.inner)
|
const segments = segmentsOf(widget)
|
||||||
// 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.
|
|
||||||
const innerName = nested ? displayName(flowOf(nested), nested) : ""
|
|
||||||
const outer = useLiveValue(message || undefined)
|
const outer = useLiveValue(message || undefined)
|
||||||
const inner = useLiveValue(nested || 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)
|
if (!message)
|
||||||
return <p className="text-sm text-muted-foreground">Pick a message.</p>
|
return <p className="text-sm text-muted-foreground">Pick a message.</p>
|
||||||
|
|
||||||
@@ -50,36 +89,66 @@ export function BarWidget({ widget }: WidgetProps) {
|
|||||||
value === null ? "—" : `${value.toFixed(precision)}${unit}`
|
value === null ? "—" : `${value.toFixed(precision)}${unit}`
|
||||||
|
|
||||||
const value = reading(outer?.value)
|
const value = reading(outer?.value)
|
||||||
const innerValue = nested ? reading(inner?.value) : null
|
|
||||||
const fraction = fractionOf(value)
|
const fraction = fractionOf(value)
|
||||||
const fits = fraction >= FITS
|
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.
|
||||||
|
name: displayName(flowOf(name), name),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const detail = drawn
|
||||||
|
.map((band) => `${write(band.level)} of it from ${band.name}`)
|
||||||
|
.join(", ")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="grid gap-1.5"
|
className="grid gap-1.5"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={
|
aria-label={
|
||||||
nested
|
detail
|
||||||
? `${write(value)} of ${max}${unit}, ${write(innerValue)} of it from ${innerName}`
|
? `${write(value)} of ${max}${unit}, ${detail}`
|
||||||
: `${write(value)} of ${max}${unit}`
|
: `${write(value)} of ${max}${unit}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="relative h-8 w-full overflow-hidden rounded-full bg-muted">
|
<div className="relative h-8 w-full overflow-hidden rounded-full bg-muted">
|
||||||
<div
|
<div
|
||||||
data-testid="bar-fill"
|
data-testid="bar-fill"
|
||||||
className="absolute inset-y-0 left-0 rounded-full bg-primary"
|
className="absolute inset-y-0 left-0 rounded-full bg-primary motion-safe:transition-[width] motion-safe:duration-200 motion-safe:ease-[var(--ease-standard)]"
|
||||||
style={{ width: `${fraction * 100}%` }}
|
style={{ width: `${fraction * 100}%` }}
|
||||||
/>
|
/>
|
||||||
{innerValue === null ? null : (
|
{drawn.map((band, index) => (
|
||||||
<div
|
<div
|
||||||
|
// Position is the only identity a segment has, as with chart series.
|
||||||
|
key={`segment-${index}`}
|
||||||
data-testid="bar-inner"
|
data-testid="bar-inner"
|
||||||
className="absolute inset-y-1 left-0 rounded-full bg-chart-5"
|
className="absolute inset-y-1 rounded-full bg-primary-nested motion-safe:transition-[left,width] motion-safe:duration-200 motion-safe:ease-[var(--ease-standard)]"
|
||||||
style={{ width: `${fractionOf(innerValue) * 100}%` }}
|
style={{
|
||||||
|
left: `${band.start * 100}%`,
|
||||||
|
// Segments share a fill, so the gutter is what tells one from the
|
||||||
|
// next: what shows between them is the outer fill they sit on.
|
||||||
|
width: `calc(${(band.end - band.start) * 100}% - ${GUTTER})`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
))}
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-y-0 flex items-center px-2 text-sm tabular-nums",
|
"absolute inset-y-0 flex items-center px-2 text-sm tabular-nums motion-safe:transition-[left,right] motion-safe:duration-200 motion-safe:ease-[var(--ease-standard)]",
|
||||||
fits ? "text-primary-foreground" : "text-foreground",
|
fits ? "text-primary-foreground" : "text-foreground",
|
||||||
)}
|
)}
|
||||||
style={
|
style={
|
||||||
@@ -91,9 +160,9 @@ export function BarWidget({ widget }: WidgetProps) {
|
|||||||
{write(value)}
|
{write(value)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{innerValue === null ? null : (
|
{drawn.length === 0 ? null : (
|
||||||
<p className="truncate text-xs text-muted-foreground">
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
{innerName} {write(innerValue)}
|
{drawn.map((band) => `${band.name} ${write(band.level)}`).join(" · ")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,6 +32,12 @@
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
grid-auto-rows: 5rem;
|
grid-auto-rows: 5rem;
|
||||||
grid-template-columns: repeat(var(--widget-cols), minmax(0, 1fr));
|
grid-template-columns: repeat(var(--widget-cols), minmax(0, 1fr));
|
||||||
|
/* Stacked, this is a flex item in an `auto` track, and its automatic minimum
|
||||||
|
size is the widest thing any widget holds — one long message name would
|
||||||
|
size the column and take the page sideways with it. The fixed-pixel canvas
|
||||||
|
the non-stacked path sits on already uses `minmax(0, 1fr)`, so this only
|
||||||
|
ever matters on a phone. See DESIGN-GUIDELINES.md -> Responsive. */
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.widget-cell {
|
.widget-cell {
|
||||||
@@ -66,6 +72,27 @@
|
|||||||
height: calc(var(--h) * 5rem + (var(--h) - 1) * 0.75rem);
|
height: calc(var(--h) * 5rem + (var(--h) - 1) * 0.75rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Motion. A value settling is a neutral state change; a selection indicator
|
||||||
|
* moving is emphasized (Material). `<MotionConfig reducedMotion="user">` only
|
||||||
|
* covers `motion/react`, so CSS asks for itself.
|
||||||
|
*/
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
/* 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 {
|
||||||
|
transition:
|
||||||
|
left var(--duration-base) var(--ease-emphasized),
|
||||||
|
width var(--duration-base) var(--ease-emphasized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* uPlot, routed through the tokens. Its own legend is the hover readout as
|
* uPlot, routed through the tokens. Its own legend is the hover readout as
|
||||||
* well — the value each line carried at the cursor — so it is styled as chart
|
* well — the value each line carried at the cursor — so it is styled as chart
|
||||||
@@ -77,6 +104,12 @@
|
|||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Series labels default to the message name, which has no spaces to break at
|
||||||
|
— and a table cannot lay out below its min-content width. */
|
||||||
|
.u-legend th {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.u-legend .u-marker {
|
.u-legend .u-marker {
|
||||||
width: 0.5rem;
|
width: 0.5rem;
|
||||||
height: 0.5rem;
|
height: 0.5rem;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { MAX_SEGMENTS, type Segment, segmentsOf } from "./BarWidget"
|
||||||
import { MAX_SERIES, refreshFor } from "./ChartWidget"
|
import { MAX_SERIES, refreshFor } from "./ChartWidget"
|
||||||
import {
|
import {
|
||||||
CANVAS_PRESETS,
|
CANVAS_PRESETS,
|
||||||
@@ -227,6 +228,14 @@ export function WidgetPanel({
|
|||||||
|
|
||||||
const series = seriesOf(widget)
|
const series = seriesOf(widget)
|
||||||
const setSeries = (next: Series[]) => set({ series: next })
|
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 })
|
||||||
// The icon widget's mapping. Position is the row's identity, as with series.
|
// The icon widget's mapping. Position is the row's identity, as with series.
|
||||||
const rules = (cfg.rules ?? []) as {
|
const rules = (cfg.rules ?? []) as {
|
||||||
at?: unknown
|
at?: unknown
|
||||||
@@ -404,13 +413,54 @@ export function WidgetPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{widget.type === "bar" ? (
|
{widget.type === "bar" ? (
|
||||||
<MessagePicker
|
<div className="grid gap-2">
|
||||||
kind="bar"
|
{rows.map((segment, index) => (
|
||||||
value={str(cfg.inner)}
|
<div
|
||||||
label="Nested bar"
|
// Position is the only identity a segment row has, as with series.
|
||||||
testId="widget-inner"
|
key={`segment-${index}`}
|
||||||
onPick={(inner, inner_dtype) => set({ inner, inner_dtype })}
|
className="flex items-end gap-1.5"
|
||||||
/>
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<MessagePicker
|
||||||
|
kind="bar"
|
||||||
|
value={segment.message ?? ""}
|
||||||
|
label={index === 0 ? "Nested bar" : ""}
|
||||||
|
testId={index === 0 ? "widget-inner" : undefined}
|
||||||
|
onPick={(message, dtype) =>
|
||||||
|
setSegments(
|
||||||
|
rows.map((other, at) =>
|
||||||
|
at === index ? { ...other, message, dtype } : other,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
aria-label="Remove segment"
|
||||||
|
onClick={() =>
|
||||||
|
setSegments(rows.filter((_, at) => at !== index))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{rows.length < MAX_SEGMENTS ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 justify-self-start"
|
||||||
|
onClick={() => setSegments([...rows, {}])}
|
||||||
|
data-testid="add-segment"
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
Add segment
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{widget.type === "chart" && !querying ? (
|
{widget.type === "chart" && !querying ? (
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { BarWidget } from "./BarWidget"
|
import { BarWidget, segmentsOf } from "./BarWidget"
|
||||||
import { ChartWidget } from "./ChartWidget"
|
import { ChartWidget } from "./ChartWidget"
|
||||||
import { ClockWidget } from "./ClockWidget"
|
import { ClockWidget } from "./ClockWidget"
|
||||||
import { ForecastWidget } from "./ForecastWidget"
|
import { ForecastWidget } from "./ForecastWidget"
|
||||||
@@ -184,9 +184,13 @@ export function widgetIssue(widget: WidgetDef): string | null {
|
|||||||
if (!acceptsDtype(widget.type, dtype)) {
|
if (!acceptsDtype(widget.type, dtype)) {
|
||||||
return `${bound} is a ${dtype}; a ${WIDGET_LABELS[widget.type].toLowerCase()} cannot carry that.`
|
return `${bound} is a ${dtype}; a ${WIDGET_LABELS[widget.type].toLowerCase()} cannot carry that.`
|
||||||
}
|
}
|
||||||
// Only a bar nests a second reading, and an unrecorded type binds anything.
|
// Only a bar nests further readings, and an unrecorded type binds anything.
|
||||||
if (!acceptsDtype(widget.type, text(cfg.inner_dtype) || undefined)) {
|
// Read through `segmentsOf` so a stacked bar is judged segment by segment
|
||||||
return `${text(cfg.inner)} is a ${text(cfg.inner_dtype)}; a bar nests numbers.`
|
// rather than only in the one-reading shape it used to carry.
|
||||||
|
for (const segment of segmentsOf(widget)) {
|
||||||
|
if (!acceptsDtype(widget.type, segment.dtype || undefined)) {
|
||||||
|
return `${segment.message} is a ${segment.dtype}; a bar nests numbers.`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (widget.type === "icon" && !(cfg.rules as unknown[] | undefined)?.length) {
|
if (widget.type === "icon" && !(cfg.rules as unknown[] | undefined)?.length) {
|
||||||
return "This icon has nothing mapped yet."
|
return "This icon has nothing mapped yet."
|
||||||
@@ -235,7 +239,7 @@ export function WidgetFrame({
|
|||||||
{title || actions || issue || grip ? (
|
{title || actions || issue || grip ? (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-start justify-between gap-2",
|
"flex min-w-0 items-start justify-between gap-2",
|
||||||
grip && "widget-grip -m-1 cursor-grab p-1 active:cursor-grabbing",
|
grip && "widget-grip -m-1 cursor-grab p-1 active:cursor-grabbing",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -266,7 +270,11 @@ export function WidgetFrame({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex min-h-0 flex-1 flex-col justify-center">
|
{/* A scroller, not a clip: the header is an earlier sibling, so anything
|
||||||
|
taller than the card would otherwise paint over the title instead of
|
||||||
|
being reachable. Centring has to be `safe` — plain `center` overflows
|
||||||
|
both edges at once and puts the top of a long body out of reach. */}
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col justify-center-safe overflow-y-auto">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -352,15 +360,21 @@ function GaugeWidget({ widget }: WidgetProps) {
|
|||||||
strokeWidth={9}
|
strokeWidth={9}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
/>
|
/>
|
||||||
{fraction > 0 ? (
|
{/* The same full arc as the track, revealed by the dash: `d` is not
|
||||||
<path
|
transitionable, so a reading that re-paths the arc can only jump.
|
||||||
d={arc(start, start + sweep * fraction)}
|
`pathLength` normalises it to 1, which makes the offset the
|
||||||
fill="none"
|
fraction itself and saves measuring the geometry. */}
|
||||||
stroke="var(--primary)"
|
<path
|
||||||
strokeWidth={9}
|
className="widget-gauge-arc"
|
||||||
strokeLinecap="round"
|
d={arc(start, start + sweep)}
|
||||||
/>
|
pathLength={1}
|
||||||
) : null}
|
strokeDasharray={1}
|
||||||
|
style={{ strokeDashoffset: 1 - fraction }}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--primary)"
|
||||||
|
strokeWidth={9}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
<text
|
<text
|
||||||
x={50}
|
x={50}
|
||||||
y={54}
|
y={54}
|
||||||
@@ -391,7 +405,7 @@ function MarkdownWidget({ widget }: WidgetProps) {
|
|||||||
const content = text(config(widget).content)
|
const content = text(config(widget).content)
|
||||||
const lines = content.split("\n")
|
const lines = content.split("\n")
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-1 text-sm">
|
<div className="grid gap-1 break-words text-sm">
|
||||||
{lines.map((line, index) => {
|
{lines.map((line, index) => {
|
||||||
const heading = /^(#{1,3})\s+(.*)$/.exec(line)
|
const heading = /^(#{1,3})\s+(.*)$/.exec(line)
|
||||||
const body = heading ? heading[2] : line.replace(/^[-*]\s+/, "")
|
const body = heading ? heading[2] : line.replace(/^[-*]\s+/, "")
|
||||||
@@ -476,7 +490,7 @@ function AgendaWidget({ widget }: WidgetProps) {
|
|||||||
<li
|
<li
|
||||||
// Two entries can share a title and a time; position is the identity.
|
// Two entries can share a title and a time; position is the identity.
|
||||||
key={`item-${index}`}
|
key={`item-${index}`}
|
||||||
className="flex items-baseline gap-2"
|
className="flex min-w-0 items-baseline gap-2"
|
||||||
>
|
>
|
||||||
<span className="shrink-0 text-muted-foreground tabular-nums">
|
<span className="shrink-0 text-muted-foreground tabular-nums">
|
||||||
{dayLabel(when, now)}
|
{dayLabel(when, now)}
|
||||||
@@ -515,7 +529,7 @@ function NotificationWidget({ widget }: WidgetProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-1">
|
<div className="grid gap-1 break-words">
|
||||||
{title ? (
|
{title ? (
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -566,11 +580,13 @@ function ButtonWidget({ widget, dashboard }: WidgetProps) {
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="w-full"
|
className="w-full min-w-0"
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
onClick={() => send(cfg.value ?? true)}
|
onClick={() => send(cfg.value ?? true)}
|
||||||
>
|
>
|
||||||
{text(cfg.label, widget.title || "Send")}
|
<span className="truncate">
|
||||||
|
{text(cfg.label, widget.title || "Send")}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -590,12 +606,12 @@ function SwitchWidget({ widget, dashboard }: WidgetProps) {
|
|||||||
return cfg.style === "button" ? (
|
return cfg.style === "button" ? (
|
||||||
<Button
|
<Button
|
||||||
variant={on ? "default" : "secondary"}
|
variant={on ? "default" : "secondary"}
|
||||||
className="w-full"
|
className="w-full min-w-0"
|
||||||
aria-pressed={on}
|
aria-pressed={on}
|
||||||
aria-label={widget.title || target}
|
aria-label={widget.title || target}
|
||||||
onClick={() => send(!on)}
|
onClick={() => send(!on)}
|
||||||
>
|
>
|
||||||
{on ? "On" : "Off"}
|
<span className="truncate">{on ? "On" : "Off"}</span>
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
@@ -710,30 +726,50 @@ function DropdownWidget({ widget, dashboard }: WidgetProps) {
|
|||||||
if (!target) return <Unbound />
|
if (!target) return <Unbound />
|
||||||
|
|
||||||
if (cfg.style === "segmented") {
|
if (cfg.style === "segmented") {
|
||||||
|
const chosen = options.findIndex(
|
||||||
|
(option) => text(option.value) === text(live?.value),
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
// The one segmented shape: a single border pill, no dividers,
|
// The one segmented shape: a single border pill, no dividers,
|
||||||
// transparent segments, bg-accent on the selected one.
|
// transparent segments, bg-accent on the selected one — held by a thumb
|
||||||
<fieldset className="flex w-full items-center gap-1 rounded-full border border-border p-1">
|
// that slides rather than a fill that jumps from cell to cell. A
|
||||||
|
// `fieldset` carries `min-inline-size: min-content` from the UA sheet,
|
||||||
|
// which `w-full` does not override.
|
||||||
|
<fieldset
|
||||||
|
className="relative grid w-full min-w-0 items-center rounded-full border border-border p-1"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<legend className="sr-only">{widget.title || target}</legend>
|
<legend className="sr-only">{widget.title || target}</legend>
|
||||||
{options.map((option) => {
|
{chosen >= 0 ? (
|
||||||
const selected = text(option.value) === text(live?.value)
|
// Equal tracks and no gap, so a segment is exactly its share of the
|
||||||
return (
|
// padded box and the thumb needs no measuring.
|
||||||
<button
|
<span
|
||||||
key={text(option.value)}
|
aria-hidden
|
||||||
type="button"
|
className="widget-segment-thumb pointer-events-none absolute inset-y-1 rounded-full bg-accent"
|
||||||
aria-pressed={selected}
|
style={{
|
||||||
onClick={() => send(option.value)}
|
left: `calc(0.25rem + ${chosen} * (100% - 0.5rem) / ${options.length})`,
|
||||||
className={cn(
|
width: `calc((100% - 0.5rem) / ${options.length})`,
|
||||||
"h-11 min-w-0 flex-1 truncate rounded-full px-2.5 text-sm transition-colors md:h-8",
|
}}
|
||||||
selected
|
/>
|
||||||
? "bg-accent text-accent-foreground"
|
) : null}
|
||||||
: "text-muted-foreground hover:bg-accent/50",
|
{options.map((option, index) => (
|
||||||
)}
|
<button
|
||||||
>
|
key={text(option.value)}
|
||||||
{option.label ?? text(option.value)}
|
type="button"
|
||||||
</button>
|
aria-pressed={index === chosen}
|
||||||
)
|
onClick={() => send(option.value)}
|
||||||
})}
|
className={cn(
|
||||||
|
"relative z-10 h-11 min-w-0 truncate rounded-full px-2.5 text-sm transition-colors md:h-8",
|
||||||
|
index === chosen
|
||||||
|
? "text-accent-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.label ?? text(option.value)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -745,7 +781,7 @@ function DropdownWidget({ widget, dashboard }: WidgetProps) {
|
|||||||
value={text(live?.value)}
|
value={text(live?.value)}
|
||||||
onValueChange={(value) => send(asOriginal(value, options))}
|
onValueChange={(value) => send(asOriginal(value, options))}
|
||||||
>
|
>
|
||||||
<SelectTrigger aria-label={widget.title || target}>
|
<SelectTrigger className="w-full" aria-label={widget.title || target}>
|
||||||
<SelectValue placeholder="Choose" />
|
<SelectValue placeholder="Choose" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
--color-popover-foreground: var(--popover-foreground);
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
--color-primary: var(--primary);
|
--color-primary: var(--primary);
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary-nested: var(--primary-nested);
|
||||||
--color-brand-secondary: var(--brand-secondary);
|
--color-brand-secondary: var(--brand-secondary);
|
||||||
--color-brand-secondary-foreground: var(--brand-secondary-foreground);
|
--color-brand-secondary-foreground: var(--brand-secondary-foreground);
|
||||||
--color-secondary: var(--secondary);
|
--color-secondary: var(--secondary);
|
||||||
@@ -86,6 +87,16 @@
|
|||||||
* 4.04:1 against white, below AA for the label sitting on a bg-primary fill.
|
* 4.04:1 against white, below AA for the label sitting on a bg-primary fill.
|
||||||
* #59849b remains the wordmark colour (index/src/assets/fluksio-*.svg). See the root
|
* #59849b remains the wordmark colour (index/src/assets/fluksio-*.svg). See the root
|
||||||
* DESIGN-GUIDELINES.md → Colour tokens.
|
* DESIGN-GUIDELINES.md → Colour tokens.
|
||||||
|
*
|
||||||
|
* `--primary-nested` is that same hue and saturation a few steps deeper, for
|
||||||
|
* a reading drawn inside a `--primary` fill — the bar widget's stacked
|
||||||
|
* segments. No slot of the chart ramp clears the 3:1 non-text guideline
|
||||||
|
* against `--primary` (`--chart-5`, which the bar used, measures 2.53:1), so
|
||||||
|
* the nested fill needs a token of its own. It cannot clear 3:1 against
|
||||||
|
* `--muted` as well in dark: `--primary` and `--muted` are only 5.82:1 apart
|
||||||
|
* there, and a colour 3:1 from both would have to sit in a 9:1 gap. The
|
||||||
|
* segment is therefore drawn inside a gutter of the fill rather than ever
|
||||||
|
* bordering the track.
|
||||||
*/
|
*/
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #ffffff;
|
||||||
@@ -96,6 +107,7 @@
|
|||||||
--popover-foreground: #333232;
|
--popover-foreground: #333232;
|
||||||
--primary: #4a7189;
|
--primary: #4a7189;
|
||||||
--primary-foreground: #ffffff;
|
--primary-foreground: #ffffff;
|
||||||
|
--primary-nested: #152128; /* on --primary 3.14:1, on --muted 14.6:1 */
|
||||||
--brand-secondary: #de8f6e;
|
--brand-secondary: #de8f6e;
|
||||||
--brand-secondary-foreground: #333232;
|
--brand-secondary-foreground: #333232;
|
||||||
--secondary: #f2f2f2;
|
--secondary: #f2f2f2;
|
||||||
@@ -133,6 +145,7 @@
|
|||||||
--popover-foreground: #f5f5f5;
|
--popover-foreground: #f5f5f5;
|
||||||
--primary: #7ba3b8;
|
--primary: #7ba3b8;
|
||||||
--primary-foreground: #0a0a0a;
|
--primary-foreground: #0a0a0a;
|
||||||
|
--primary-nested: #345160; /* on --primary 3.12:1, on --muted 1.86:1 */
|
||||||
--brand-secondary: #e5a184;
|
--brand-secondary: #e5a184;
|
||||||
--brand-secondary-foreground: #0a0a0a;
|
--brand-secondary-foreground: #0a0a0a;
|
||||||
--secondary: #232323;
|
--secondary: #232323;
|
||||||
|
|||||||
@@ -40,6 +40,40 @@ async function expectFits(page: Page, where: string) {
|
|||||||
expect(scroll, `${where} scrolls sideways`).toBeLessThanOrEqual(inner)
|
expect(scroll, `${where} scrolls sideways`).toBeLessThanOrEqual(inner)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same rule, one level in.
|
||||||
|
*
|
||||||
|
* A widget body is a scroll container now — content taller than its card is
|
||||||
|
* reachable rather than painted over the title — and a scroller absorbs a
|
||||||
|
* sideways overflow before `document.scrollWidth` ever sees it. So the boxes
|
||||||
|
* are checked for themselves.
|
||||||
|
*
|
||||||
|
* Only a box the user can actually drag sideways counts: `truncate` is
|
||||||
|
* `overflow: hidden`, and hidden content reports a wider `scrollWidth` too
|
||||||
|
* without anyone being able to reach it. Of the ones that can, only a box that
|
||||||
|
* asked for it — `overflow-x-auto`, per DESIGN-GUIDELINES.md -> Responsive —
|
||||||
|
* is allowed to.
|
||||||
|
*/
|
||||||
|
async function expectNoInnerScroll(page: Page, where: string) {
|
||||||
|
const wide = await page.evaluate(() =>
|
||||||
|
[
|
||||||
|
...document.querySelectorAll<HTMLElement>(
|
||||||
|
"[data-testid=dashboard-canvas] *, main *",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.filter(
|
||||||
|
(el) =>
|
||||||
|
!el.classList.contains("overflow-x-auto") &&
|
||||||
|
["auto", "scroll"].includes(getComputedStyle(el).overflowX) &&
|
||||||
|
el.scrollWidth > el.clientWidth + 1,
|
||||||
|
)
|
||||||
|
.map((el) => `${el.tagName}.${el.className}`.slice(0, 120)),
|
||||||
|
)
|
||||||
|
expect(wide, `${where} has a sideways scroller: ${wide.join(" | ")}`).toEqual(
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
test.beforeAll(async ({ browser }) => {
|
test.beforeAll(async ({ browser }) => {
|
||||||
const page = await apiPage(browser)
|
const page = await apiPage(browser)
|
||||||
|
|
||||||
@@ -91,6 +125,10 @@ test.beforeAll(async ({ browser }) => {
|
|||||||
{ name: "level", dtype: "float" },
|
{ name: "level", dtype: "float" },
|
||||||
{ name: "pv", dtype: "float" },
|
{ name: "pv", dtype: "float" },
|
||||||
{ name: "days", dtype: "list", item: "record" },
|
{ name: "days", dtype: "list", item: "record" },
|
||||||
|
// Long, and with nothing to break at: an unlabelled series puts
|
||||||
|
// this whole name in the chart's legend.
|
||||||
|
{ name: "climate_series_reading", dtype: "float" },
|
||||||
|
{ name: "mode", dtype: "str" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -112,6 +150,14 @@ test.beforeAll(async ({ browser }) => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
data: { value: 24 },
|
data: { value: 24 },
|
||||||
})
|
})
|
||||||
|
// A chart only builds once it has a reading, and an unbuilt chart has no
|
||||||
|
// legend to overflow.
|
||||||
|
for (const value of [12, 14, 13]) {
|
||||||
|
await api(page, `/messages/${feedName}.climate_series_reading`, {
|
||||||
|
method: "POST",
|
||||||
|
data: { value },
|
||||||
|
})
|
||||||
|
}
|
||||||
await api(page, `/messages/${feedName}.days`, {
|
await api(page, `/messages/${feedName}.days`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
data: {
|
data: {
|
||||||
@@ -167,6 +213,52 @@ test.beforeAll(async ({ browser }) => {
|
|||||||
layout: { lg: { x: 0, y: 4, w: 6, h: 2 } },
|
layout: { lg: { x: 0, y: 4, w: 6, h: 2 } },
|
||||||
config: { message: `${feedName}.days`, dtype: "list", count: 5 },
|
config: { message: `${feedName}.days`, dtype: "list", count: 5 },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// No label, so uPlot's legend carries the message name — a table cell
|
||||||
|
// holding one unbroken token.
|
||||||
|
id: "trend",
|
||||||
|
type: "chart",
|
||||||
|
title: "Trend",
|
||||||
|
layout: { lg: { x: 0, y: 6, w: 6, h: 4 } },
|
||||||
|
config: {
|
||||||
|
series: [
|
||||||
|
{ message: `${feedName}.climate_series_reading`, dtype: "float" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Five segments of prose in a pill that has to fit a phone.
|
||||||
|
id: "mode",
|
||||||
|
type: "dropdown",
|
||||||
|
title: "Mode",
|
||||||
|
layout: { lg: { x: 0, y: 10, w: 4, h: 2 } },
|
||||||
|
config: {
|
||||||
|
target: `${feedName}.mode`,
|
||||||
|
dtype: "str",
|
||||||
|
style: "segmented",
|
||||||
|
options: [
|
||||||
|
{ label: "Comfort heating", value: "comfort" },
|
||||||
|
{ label: "Economy overnight", value: "economy" },
|
||||||
|
{ label: "Away from home", value: "away" },
|
||||||
|
{ label: "Boost for an hour", value: "boost" },
|
||||||
|
{ label: "Frost protection only", value: "frost" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Far taller than the card it is given: the body has to scroll rather
|
||||||
|
// than run out under the title.
|
||||||
|
id: "notes",
|
||||||
|
type: "markdown",
|
||||||
|
title: "Notes",
|
||||||
|
layout: { lg: { x: 0, y: 12, w: 6, h: 2 } },
|
||||||
|
config: {
|
||||||
|
content: Array.from(
|
||||||
|
{ length: 40 },
|
||||||
|
(_, index) => `- Line ${index + 1}`,
|
||||||
|
).join("\n"),
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
const draft = await (
|
const draft = await (
|
||||||
await api(page, `/dashboards/${dashboardName}`, {
|
await api(page, `/dashboards/${dashboardName}`, {
|
||||||
@@ -259,6 +351,7 @@ test("a dashboard stacks instead of shrinking", async ({ page }) => {
|
|||||||
await page.goto(`/dashboards/${dashboardName}`)
|
await page.goto(`/dashboards/${dashboardName}`)
|
||||||
await page.waitForSelector("[data-testid=widget-frame]", { timeout: 15000 })
|
await page.waitForSelector("[data-testid=widget-frame]", { timeout: 15000 })
|
||||||
await expectFits(page, "the dashboard editor")
|
await expectFits(page, "the dashboard editor")
|
||||||
|
await expectNoInnerScroll(page, "the dashboard editor")
|
||||||
|
|
||||||
// Side by side on a panel, one under the other here.
|
// Side by side on a panel, one under the other here.
|
||||||
const first = await page.getByTestId("widget-frame").first().boundingBox()
|
const first = await page.getByTestId("widget-frame").first().boundingBox()
|
||||||
@@ -271,5 +364,38 @@ test("a dashboard stacks instead of shrinking", async ({ page }) => {
|
|||||||
test("the panel view fits the viewport", async ({ page }) => {
|
test("the panel view fits the viewport", async ({ page }) => {
|
||||||
await page.goto(`/view/${dashboardName}`)
|
await page.goto(`/view/${dashboardName}`)
|
||||||
await page.waitForSelector("[data-testid=widget-frame]", { timeout: 15000 })
|
await page.waitForSelector("[data-testid=widget-frame]", { timeout: 15000 })
|
||||||
|
// uPlot's legend is the widest thing on the page and only exists once the
|
||||||
|
// chart has drawn, so there is nothing to measure until it does.
|
||||||
|
await page.locator(".u-legend").first().waitFor({ timeout: 15000 })
|
||||||
await expectFits(page, "the panel view")
|
await expectFits(page, "the panel view")
|
||||||
|
await expectNoInnerScroll(page, "the panel view")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a widget scrolls rather than running out under its title", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto(`/view/${dashboardName}`)
|
||||||
|
const notes = page
|
||||||
|
.getByTestId("widget-frame")
|
||||||
|
.filter({ hasText: "Notes" })
|
||||||
|
.first()
|
||||||
|
await notes.waitFor({ timeout: 15000 })
|
||||||
|
|
||||||
|
// The body, not the card: the card clips, and clipping is what used to let
|
||||||
|
// the lines paint over the header rather than scroll under it.
|
||||||
|
const scrolls = await notes.evaluate((frame) =>
|
||||||
|
[...frame.children].some(
|
||||||
|
(child) => child.scrollHeight > child.clientHeight + 1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expect(scrolls, "forty lines fit a two-row card").toBe(true)
|
||||||
|
|
||||||
|
const title = await notes.getByText("Notes", { exact: true }).boundingBox()
|
||||||
|
const first = await notes.getByText("• Line 1", { exact: true }).boundingBox()
|
||||||
|
expect(title).not.toBeNull()
|
||||||
|
expect(first).not.toBeNull()
|
||||||
|
expect(
|
||||||
|
first!.y,
|
||||||
|
"the first line is drawn over the title",
|
||||||
|
).toBeGreaterThanOrEqual(title!.y + title!.height - 1)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { api, apiPage, deleteAll } from "./utils/api"
|
|||||||
|
|
||||||
const flowName = `test_widgets_${Date.now().toString(36)}`
|
const flowName = `test_widgets_${Date.now().toString(36)}`
|
||||||
const dashboardName = `${flowName}_panel`
|
const dashboardName = `${flowName}_panel`
|
||||||
|
/** A panel of its own: a stacked bar needs the only `bar-inner` on the page. */
|
||||||
|
const stackName = `${flowName}_stack`
|
||||||
|
|
||||||
/** A message of the flow under test, qualified the way the engine names it. */
|
/** A message of the flow under test, qualified the way the engine names it. */
|
||||||
const w = (name: string) => `${flowName}.${name}`
|
const w = (name: string) => `${flowName}.${name}`
|
||||||
@@ -47,6 +49,7 @@ test.beforeAll(async ({ browser }) => {
|
|||||||
provides: [
|
provides: [
|
||||||
{ name: "level", dtype: "float" },
|
{ name: "level", dtype: "float" },
|
||||||
{ name: "pv", dtype: "float" },
|
{ name: "pv", dtype: "float" },
|
||||||
|
{ name: "grid", dtype: "float" },
|
||||||
{ name: "condition", dtype: "str" },
|
{ name: "condition", dtype: "str" },
|
||||||
{ name: "days", dtype: "list", item: "record" },
|
{ name: "days", dtype: "list", item: "record" },
|
||||||
{ name: "mode", dtype: "str" },
|
{ name: "mode", dtype: "str" },
|
||||||
@@ -66,6 +69,7 @@ test.beforeAll(async ({ browser }) => {
|
|||||||
|
|
||||||
await publish(page, w("level"), 80)
|
await publish(page, w("level"), 80)
|
||||||
await publish(page, w("pv"), 30)
|
await publish(page, w("pv"), 30)
|
||||||
|
await publish(page, w("grid"), 20)
|
||||||
await publish(page, w("condition"), "sun")
|
await publish(page, w("condition"), "sun")
|
||||||
await publish(page, w("days"), [
|
await publish(page, w("days"), [
|
||||||
{ label: "Mon", icon: "sun", value: "21°" },
|
{ label: "Mon", icon: "sun", value: "21°" },
|
||||||
@@ -174,22 +178,75 @@ test.beforeAll(async ({ browser }) => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
data: { version: draft.version },
|
data: { version: draft.version },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await api(page, `/dashboards/${stackName}`, { method: "POST" })
|
||||||
|
const stack = await (await api(page, `/dashboards/${stackName}`)).json()
|
||||||
|
stack.pages[0].sections[0].widgets = [
|
||||||
|
{
|
||||||
|
id: "split",
|
||||||
|
type: "bar",
|
||||||
|
title: "Split",
|
||||||
|
layout: { lg: { x: 0, y: 0, w: 6, h: 2 } },
|
||||||
|
config: {
|
||||||
|
message: w("level"),
|
||||||
|
dtype: "float",
|
||||||
|
// The list shape. The panel above keeps the single binding a bar was
|
||||||
|
// written with, which is what proves both are still read.
|
||||||
|
inner: [
|
||||||
|
{ message: w("pv"), dtype: "float" },
|
||||||
|
{ message: w("grid"), dtype: "float" },
|
||||||
|
],
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
unit: " kW",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const stacked = await (
|
||||||
|
await api(page, `/dashboards/${stackName}`, { method: "PUT", data: stack })
|
||||||
|
).json()
|
||||||
|
await api(page, `/dashboards/${stackName}/publish`, {
|
||||||
|
method: "POST",
|
||||||
|
data: { version: stacked.version },
|
||||||
|
})
|
||||||
await page.close()
|
await page.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
test.afterAll(async ({ browser }) => {
|
test.afterAll(async ({ browser }) => {
|
||||||
await deleteAll(browser, [
|
await deleteAll(browser, [
|
||||||
`/dashboards/${dashboardName}`,
|
`/dashboards/${dashboardName}`,
|
||||||
|
`/dashboards/${stackName}`,
|
||||||
`/flows/${flowName}`,
|
`/flows/${flowName}`,
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
/** The panel as a wall panel opens it, once the tiles are drawn. */
|
/** The panel as a wall panel opens it, once the tiles are drawn. */
|
||||||
async function openPanel(page: Page) {
|
async function openPanel(page: Page, name = dashboardName) {
|
||||||
await page.goto(`/view/${dashboardName}`)
|
await page.goto(`/view/${name}`)
|
||||||
await page.waitForSelector("[data-testid=widget-frame]", { timeout: 15000 })
|
await page.waitForSelector("[data-testid=widget-frame]", { timeout: 15000 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WCAG contrast of two `rgb(...)` paints, so a fill can be held to the 3:1
|
||||||
|
* guideline for non-text rather than eyeballed on a screenshot.
|
||||||
|
*/
|
||||||
|
function contrast(first: string, second: string) {
|
||||||
|
const luminance = (paint: string) => {
|
||||||
|
const channel = (value: number) => {
|
||||||
|
const scaled = value / 255
|
||||||
|
return scaled <= 0.03928
|
||||||
|
? scaled / 12.92
|
||||||
|
: ((scaled + 0.055) / 1.055) ** 2.4
|
||||||
|
}
|
||||||
|
const [r, g, b] = (paint.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number)
|
||||||
|
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
|
||||||
|
}
|
||||||
|
const [dark, light] = [luminance(first), luminance(second)].sort(
|
||||||
|
(a, b) => a - b,
|
||||||
|
)
|
||||||
|
return (light + 0.05) / (dark + 0.05)
|
||||||
|
}
|
||||||
|
|
||||||
test("a nested bar is drawn inside its outer fill", async ({ page }) => {
|
test("a nested bar is drawn inside its outer fill", async ({ page }) => {
|
||||||
await openPanel(page)
|
await openPanel(page)
|
||||||
|
|
||||||
@@ -207,6 +264,51 @@ test("a nested bar is drawn inside its outer fill", async ({ page }) => {
|
|||||||
).toBeLessThan(outerBox!.width)
|
).toBeLessThan(outerBox!.width)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("a nested reading larger than the outer one is clamped to it", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await openPanel(page)
|
||||||
|
|
||||||
|
await publish(page, w("pv"), 120)
|
||||||
|
// Written from the same reading, so the caption says when it landed.
|
||||||
|
await expect(page.getByText(/120\.0 kW/)).toBeVisible()
|
||||||
|
|
||||||
|
const outerBox = await page.getByTestId("bar-fill").boundingBox()
|
||||||
|
const innerBox = await page.getByTestId("bar-inner").boundingBox()
|
||||||
|
expect(
|
||||||
|
innerBox!.width,
|
||||||
|
"a nested value over the reading spills onto the track",
|
||||||
|
).toBeLessThanOrEqual(outerBox!.width)
|
||||||
|
|
||||||
|
await publish(page, w("pv"), 30)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a second nested reading starts where the first ends", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await openPanel(page, stackName)
|
||||||
|
|
||||||
|
const segments = page.getByTestId("bar-inner")
|
||||||
|
await expect(segments).toHaveCount(2)
|
||||||
|
const first = await segments.nth(0).boundingBox()
|
||||||
|
const second = await segments.nth(1).boundingBox()
|
||||||
|
const outerBox = await page.getByTestId("bar-fill").boundingBox()
|
||||||
|
|
||||||
|
// Stacked rather than drawn over one another: the only gap between them is
|
||||||
|
// the gutter that tells them apart, and neither leaves the fill.
|
||||||
|
const gap = second!.x - (first!.x + first!.width)
|
||||||
|
expect(gap, "the segments are drawn over one another").toBeGreaterThanOrEqual(
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
gap,
|
||||||
|
"the second segment does not follow the first",
|
||||||
|
).toBeLessThanOrEqual(3)
|
||||||
|
expect(second!.x + second!.width).toBeLessThanOrEqual(
|
||||||
|
outerBox!.x + outerBox!.width + 1,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test("the icon follows what the message says", async ({ page }) => {
|
test("the icon follows what the message says", async ({ page }) => {
|
||||||
await openPanel(page)
|
await openPanel(page)
|
||||||
const glyph = page.getByTestId("icon-glyph")
|
const glyph = page.getByTestId("icon-glyph")
|
||||||
@@ -306,6 +408,19 @@ for (const scheme of ["light", "dark"] as const) {
|
|||||||
test(`the panel reads in ${scheme}`, async ({ page }) => {
|
test(`the panel reads in ${scheme}`, async ({ page }) => {
|
||||||
await openPanel(page)
|
await openPanel(page)
|
||||||
await expect(page.getByTestId("bar-inner")).toBeVisible()
|
await expect(page.getByTestId("bar-inner")).toBeVisible()
|
||||||
|
|
||||||
|
// The nested fill is a picture, so it owes the 3:1 guideline for
|
||||||
|
// non-text against the fill it sits on — measured, not eyeballed.
|
||||||
|
const paint = (testId: string) =>
|
||||||
|
page
|
||||||
|
.getByTestId(testId)
|
||||||
|
.evaluate((el) => getComputedStyle(el).backgroundColor)
|
||||||
|
const ratio = contrast(await paint("bar-fill"), await paint("bar-inner"))
|
||||||
|
expect(
|
||||||
|
ratio,
|
||||||
|
`the nested fill measures ${ratio.toFixed(2)}:1 on the outer one`,
|
||||||
|
).toBeGreaterThanOrEqual(3)
|
||||||
|
|
||||||
await page.screenshot({ path: `screenshots/widgets/${scheme}.png` })
|
await page.screenshot({ path: `screenshots/widgets/${scheme}.png` })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user