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:
@@ -1,10 +1,9 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Ban, ChevronDown, Plus, X } from "lucide-react"
|
||||
import { Ban, ChevronDown, Plus, RotateCw, X } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
import type { MessageInfo, SettingDef, WidgetDef } from "@/client"
|
||||
import { DEFAULT_RANGE, RANGES } from "@/components/Common/RangePicker"
|
||||
import { CHART_SLOTS, paletteOf } from "@/components/Common/UplotChart"
|
||||
import {
|
||||
PANEL_SECTION,
|
||||
PanelTitle,
|
||||
@@ -36,7 +35,6 @@ import {
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { MAX_SEGMENTS, type Segment, segmentsOf } from "./BarWidget"
|
||||
import { MAX_SERIES, refreshFor } from "./ChartWidget"
|
||||
import { COLOR_DTYPES, COLOR_FORMATS, colorFormatOf } from "./ColorWidget"
|
||||
import {
|
||||
@@ -49,12 +47,16 @@ import {
|
||||
import { ICON_COLORS, ICON_NAMES, ICONS } from "./icons"
|
||||
import { messageCatalogQueryOptions } from "./queries"
|
||||
import {
|
||||
LOOK_CHOICES,
|
||||
lookOf,
|
||||
SETTING_DTYPES,
|
||||
type SettingName,
|
||||
settingIssue,
|
||||
settingOf,
|
||||
THEME_CHOICES,
|
||||
} from "./settings"
|
||||
import { type BarRow, MAX_ROWS, rowsOf, showTitle } from "./ui/core/config"
|
||||
import { parsePalette, roleLabel } from "./ui/core/theme"
|
||||
import {
|
||||
acceptsDtype,
|
||||
INPUT_WIDGETS,
|
||||
@@ -70,6 +72,10 @@ const config = (widget: WidgetDef) =>
|
||||
|
||||
const str = (value: unknown) => (value == null ? "" : String(value))
|
||||
|
||||
/** A number field left blank inherits rather than reading as zero. */
|
||||
const numberOrNone = (raw: string) =>
|
||||
raw.trim() === "" ? undefined : Number(raw)
|
||||
|
||||
/** Which messages this kind of widget may be pointed at. */
|
||||
function choicesFor(kind: WidgetKind, catalog: MessageInfo[]): MessageInfo[] {
|
||||
const input = INPUT_WIDGETS.has(kind)
|
||||
@@ -273,14 +279,20 @@ export function WidgetPanel({
|
||||
|
||||
const series = seriesOf(widget)
|
||||
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 })
|
||||
// A bar's readings. An empty row stands in for none, so a fresh bar offers
|
||||
// the picker rather than only a button.
|
||||
const bound = rowsOf(widget)
|
||||
const rows: BarRow[] = bound.length ? bound : [{}]
|
||||
/** Writing rows is also what retires the shape a bar was stored in before. */
|
||||
const setRows = (next: BarRow[]) =>
|
||||
set({
|
||||
rows: next,
|
||||
message: undefined,
|
||||
dtype: undefined,
|
||||
inner: undefined,
|
||||
inner_dtype: undefined,
|
||||
inner_label: undefined,
|
||||
})
|
||||
// The icon widget's mapping. Position is the row's identity, as with series.
|
||||
const rules = (cfg.rules ?? []) as {
|
||||
at?: unknown
|
||||
@@ -337,6 +349,20 @@ export function WidgetPanel({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* The title is still the widget's name — what a screen reader calls
|
||||
its controls, and what a publish is labelled with. This is only
|
||||
whether the panel draws it: a row of gauges under one heading
|
||||
reads better without four repeated captions above them. */}
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
Show title
|
||||
<Switch
|
||||
checked={showTitle(widget)}
|
||||
aria-label="Show title"
|
||||
data-testid="widget-show-title"
|
||||
onCheckedChange={(value) => set({ show_title: value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{widget.type === "markdown" ? (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm font-normal">Text</Label>
|
||||
@@ -446,8 +472,9 @@ export function WidgetPanel({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : // A clock reads the wall; a picker would bind a message nothing reads.
|
||||
widget.type === "clock" ? null : (
|
||||
) : // A clock reads the wall; a picker would bind a message nothing
|
||||
// reads. A bar binds a row at a time, below.
|
||||
widget.type === "clock" || widget.type === "bar" ? null : (
|
||||
<MessagePicker
|
||||
kind={widget.type}
|
||||
value={str(cfg[isInput ? "target" : "message"])}
|
||||
@@ -470,51 +497,130 @@ export function WidgetPanel({
|
||||
</div>
|
||||
|
||||
{widget.type === "bar" ? (
|
||||
<div className="grid gap-2">
|
||||
{rows.map((segment, index) => (
|
||||
<div className="grid gap-3">
|
||||
{rows.map((row, index) => (
|
||||
<div
|
||||
// Position is the only identity a segment row has, as with series.
|
||||
key={`segment-${index}`}
|
||||
className="flex items-end gap-1.5"
|
||||
// Position is the only identity a row has, as with series.
|
||||
key={`row-${index}`}
|
||||
className="grid 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(
|
||||
<div className="flex items-end gap-1.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<MessagePicker
|
||||
kind="bar"
|
||||
value={row.message ?? ""}
|
||||
label={index === 0 ? "Draws" : ""}
|
||||
testId={index === 0 ? "widget-message" : undefined}
|
||||
onPick={(message, dtype) =>
|
||||
setRows(
|
||||
rows.map((other, at) =>
|
||||
at === index ? { ...other, message, dtype } : other,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
className="w-28"
|
||||
value={row.label ?? ""}
|
||||
placeholder="Label"
|
||||
aria-label="Row label"
|
||||
onChange={(event) =>
|
||||
setRows(
|
||||
rows.map((other, at) =>
|
||||
at === index ? { ...other, message, dtype } : other,
|
||||
at === index
|
||||
? { ...other, label: event.target.value }
|
||||
: other,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
aria-label="Remove row"
|
||||
onClick={() =>
|
||||
setRows(rows.filter((_, at) => at !== index))
|
||||
}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Blank inherits the widget's own scale below, which is what
|
||||
a bar of comparable readings wants. A row that measures
|
||||
something else — a percentage beside a load in kW — says
|
||||
so here. */}
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
type="number"
|
||||
className="w-20"
|
||||
placeholder="Min"
|
||||
aria-label="Row minimum"
|
||||
value={row.min === undefined ? "" : String(row.min)}
|
||||
onChange={(event) =>
|
||||
setRows(
|
||||
rows.map((other, at) =>
|
||||
at === index
|
||||
? {
|
||||
...other,
|
||||
min: numberOrNone(event.target.value),
|
||||
}
|
||||
: other,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
className="w-20"
|
||||
placeholder="Max"
|
||||
aria-label="Row maximum"
|
||||
value={row.max === undefined ? "" : String(row.max)}
|
||||
onChange={(event) =>
|
||||
setRows(
|
||||
rows.map((other, at) =>
|
||||
at === index
|
||||
? {
|
||||
...other,
|
||||
max: numberOrNone(event.target.value),
|
||||
}
|
||||
: other,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
className="min-w-0 flex-1"
|
||||
placeholder="Unit"
|
||||
aria-label="Row unit"
|
||||
value={row.unit ?? ""}
|
||||
onChange={(event) =>
|
||||
setRows(
|
||||
rows.map((other, at) =>
|
||||
at === index
|
||||
? {
|
||||
...other,
|
||||
unit: event.target.value || undefined,
|
||||
}
|
||||
: 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 ? (
|
||||
{rows.length < MAX_ROWS ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 justify-self-start"
|
||||
onClick={() => setSegments([...rows, {}])}
|
||||
data-testid="add-segment"
|
||||
onClick={() => setRows([...rows, {}])}
|
||||
data-testid="add-row"
|
||||
>
|
||||
<Plus />
|
||||
Add segment
|
||||
Add row
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1039,9 +1145,101 @@ function SettingBinding({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The dashboard's colours, pasted or typed.
|
||||
*
|
||||
* A link is the fastest way to a palette somebody already likes, so anything
|
||||
* with hex in it is read — a coolors.co link, a colorhunt one, a comma list, a
|
||||
* column of `#rrggbb`. What is kept is the order, because order is the role.
|
||||
*
|
||||
* The text is held locally so a half-typed link is not fought over while it is
|
||||
* being typed; the parsed colours are written through on every keystroke, which
|
||||
* is what makes pasting a link show the dashboard immediately.
|
||||
*/
|
||||
function PalettePicker({
|
||||
palette,
|
||||
onChange,
|
||||
}: {
|
||||
palette: string[]
|
||||
onChange: (palette: string[]) => void
|
||||
}) {
|
||||
const [draft, setDraft] = useState(palette.join(" "))
|
||||
|
||||
const write = (text: string) => {
|
||||
setDraft(text)
|
||||
const next = parsePalette(text)
|
||||
if (next.join(" ") !== palette.join(" ")) onChange(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Input
|
||||
placeholder="Paste a coolors.co link, or hex colours"
|
||||
aria-label="Palette"
|
||||
data-testid="dashboard-palette-input"
|
||||
value={draft}
|
||||
onChange={(event) => write(event.target.value)}
|
||||
/>
|
||||
{palette.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No palette — this dashboard keeps the app's own colours.
|
||||
</p>
|
||||
) : (
|
||||
<div
|
||||
className="flex flex-wrap items-end gap-2"
|
||||
data-testid="dashboard-palette"
|
||||
>
|
||||
{palette.map((hex, index) => (
|
||||
<span
|
||||
// Position is the role, so it is also the identity: the same
|
||||
// colour twice is two different jobs.
|
||||
key={`${hex}-${index}`}
|
||||
className="grid justify-items-center gap-1"
|
||||
>
|
||||
<span
|
||||
className="size-8 rounded-full border border-border"
|
||||
style={{ background: hex }}
|
||||
aria-label={`${roleLabel(index)} ${hex}`}
|
||||
role="img"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{roleLabel(index)}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{/* The roles are positional, and a palette rarely arrives in the
|
||||
order a dashboard wants them. Turning it is quicker than
|
||||
retyping five colours. */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Rotate palette"
|
||||
data-testid="dashboard-palette-rotate"
|
||||
disabled={palette.length < 2}
|
||||
onClick={() => {
|
||||
const next = [...palette.slice(1), palette[0]]
|
||||
setDraft(next.join(" "))
|
||||
onChange(next)
|
||||
}}
|
||||
>
|
||||
<RotateCw />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** How a setting's own value reads in the "nothing is driving it" line. */
|
||||
function valueLabel(name: SettingName, value: unknown): string {
|
||||
if (name === "locked") return value === true ? "read-only" : "editable"
|
||||
if (name === "touch") return value === true ? "touch friendly" : "pointer"
|
||||
if (name === "look") return lookOf(value)
|
||||
if (name === "background") return value ? "that image" : "no image"
|
||||
if (name === "palette") {
|
||||
const count = parsePalette(value).length
|
||||
return count ? `those ${count} colours` : "no palette"
|
||||
}
|
||||
const chosen = THEME_CHOICES.find(([option]) => option === value)
|
||||
return (chosen?.[1] ?? "System").toLowerCase()
|
||||
}
|
||||
@@ -1072,8 +1270,11 @@ export function DashboardPanel({
|
||||
const canvas = canvasOf(dashboard)
|
||||
const theme = settingOf(dashboard, "theme")
|
||||
const locked = settingOf(dashboard, "locked")
|
||||
const look = settingOf(dashboard, "look")
|
||||
const background = settingOf(dashboard, "background")
|
||||
const touch = settingOf(dashboard, "touch")
|
||||
const paletteSetting = settingOf(dashboard, "palette")
|
||||
const palette = paletteOf(paletteSetting.value)
|
||||
const palette = parsePalette(paletteSetting.value)
|
||||
|
||||
/** Settings are a map, so one of them changing rewrites the whole of it. */
|
||||
const setSetting = (name: SettingName, setting: SettingDef) =>
|
||||
@@ -1205,6 +1406,28 @@ export function DashboardPanel({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Look</span>
|
||||
<Segmented
|
||||
value={lookOf(look.value)}
|
||||
options={LOOK_CHOICES}
|
||||
label="Dashboard look"
|
||||
testId="dashboard-look"
|
||||
onChange={(value) => setSetting("look", { ...look, value })}
|
||||
/>
|
||||
<SettingBinding
|
||||
name="look"
|
||||
setting={look}
|
||||
onChange={(setting) => setSetting("look", setting)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
How this dashboard is drawn. Glass floats translucent tiles over a
|
||||
soft moving ground; Material lays flat tonal cards on a plain one.
|
||||
The widgets are the same either way — a look changes what they
|
||||
look like and nothing about what they do.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Theme</span>
|
||||
<Segmented
|
||||
@@ -1224,55 +1447,85 @@ export function DashboardPanel({
|
||||
wall has nobody to set the device preference System otherwise
|
||||
follows. Bind a message and a flow drives it instead: a node
|
||||
publishing on a cron is what a schedule looks like here, and the
|
||||
choice above stays the fallback.
|
||||
choice above stays the fallback. A palette settles this for
|
||||
itself: its first colour is the ground, so a dashboard that names
|
||||
one is already light or dark and this is left idle.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Palette</span>
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
data-testid="dashboard-palette"
|
||||
>
|
||||
{CHART_SLOTS.map((slot) => {
|
||||
const picked = palette.includes(slot)
|
||||
return (
|
||||
<button
|
||||
key={slot}
|
||||
type="button"
|
||||
aria-pressed={picked}
|
||||
aria-label={`Colour ${slot}`}
|
||||
onClick={() =>
|
||||
setSetting("palette", {
|
||||
...paletteSetting,
|
||||
value: picked
|
||||
? palette.filter((other) => other !== slot)
|
||||
: [...palette, slot],
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
"size-11 rounded-full transition-shadow md:size-8",
|
||||
picked &&
|
||||
"ring-2 ring-ring ring-offset-2 ring-offset-card",
|
||||
)}
|
||||
style={{
|
||||
// A token alpha rather than `opacity`, which would fade
|
||||
// the ring with the swatch.
|
||||
background: picked
|
||||
? `var(--chart-${slot})`
|
||||
: `color-mix(in srgb, var(--chart-${slot}) 30%, transparent)`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<PalettePicker
|
||||
palette={palette}
|
||||
onChange={(value: string[]) =>
|
||||
setSetting("palette", { ...paletteSetting, value })
|
||||
}
|
||||
/>
|
||||
<SettingBinding
|
||||
name="palette"
|
||||
setting={paletteSetting}
|
||||
onChange={(setting) => setSetting("palette", setting)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The colours this dashboard's charts draw with, taken in the order
|
||||
you pick them — a chart with more lines than colours starts over
|
||||
at the first. Pick none and each chart spreads itself across the
|
||||
range by how many lines it has, which is usually what you want.
|
||||
Each colour is offered once: two lines sharing one could not be
|
||||
told apart, and neighbouring ones are close enough already.
|
||||
The colours this dashboard is drawn in, in order: the ground, the
|
||||
surface a widget is, the primary, the accent, and the text. Leave
|
||||
the later ones off and they are worked out from the ones you gave
|
||||
— three colours are a whole dashboard. Anything past the five is
|
||||
another colour for a chart to draw a line in. Name none and the
|
||||
dashboard keeps the app's own.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Background</span>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://…"
|
||||
aria-label="Background image"
|
||||
data-testid="dashboard-background"
|
||||
value={str(background.value)}
|
||||
onChange={(event) =>
|
||||
setSetting("background", {
|
||||
...background,
|
||||
value: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<SettingBinding
|
||||
name="background"
|
||||
setting={background}
|
||||
onChange={(setting) => setSetting("background", setting)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
An image drawn under the widgets, covering the canvas. It takes
|
||||
the place of the ground the Glass look brings with it. Bind a
|
||||
message and a flow decides the picture — one per season, or one
|
||||
per time of day.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Touch</span>
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
Touch friendly
|
||||
<Switch
|
||||
checked={touch.value === true}
|
||||
aria-label="Touch friendly"
|
||||
data-testid="dashboard-touch"
|
||||
onCheckedChange={(value) =>
|
||||
setSetting("touch", { ...touch, value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<SettingBinding
|
||||
name="touch"
|
||||
setting={touch}
|
||||
onChange={(setting) => setSetting("touch", setting)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Bigger controls, and nothing that only happens on hover — for a
|
||||
panel that is touched rather than pointed at. A phone gets this
|
||||
anyway; a wall panel has no way to say so for itself.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user