Bump dashboards: bar, icon, forecast and clock widget types

Plumbing only: the widget-type literal and its dtype table on both sides,
the regenerated client, a curated lucide map and four stubs the renderers
are wired to. Also a latching switch and a segmented dropdown, both a
`style` on the control that already publishes and reads back, plus the
option editor a dropdown never had.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HTsT1isxUjw5gtkJk8WhuA
This commit is contained in:
2026-08-20 08:31:36 +02:00
co-authored by Claude Opus 5
parent 6be718f672
commit dbaa3518b9
11 changed files with 389 additions and 22 deletions
+1 -1
View File
@@ -2907,7 +2907,7 @@ export const WidgetDefSchema = {
},
type: {
type: 'string',
enum: ['stat', 'gauge', 'chart', 'markdown', 'agenda', 'notification', 'button', 'switch', 'slider', 'input', 'dropdown'],
enum: ['stat', 'gauge', 'chart', 'markdown', 'agenda', 'notification', 'bar', 'icon', 'forecast', 'clock', 'button', 'switch', 'slider', 'input', 'dropdown'],
title: 'Type'
},
title: {
+2 -2
View File
@@ -967,7 +967,7 @@ export type ValidationResult = {
*/
export type WidgetDef = {
id: string;
type: 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown';
type: 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown';
title?: string;
layout?: {
[key: string]: Placement;
@@ -977,7 +977,7 @@ export type WidgetDef = {
};
};
export type type = 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown';
export type type = 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown';
export type WorkerInfo = {
name: string;
@@ -0,0 +1,6 @@
import type { WidgetProps } from "./widgets"
/** A bar — a stub until the bar widget is written. */
export function BarWidget(_props: WidgetProps) {
return <p className="text-sm text-muted-foreground"></p>
}
@@ -0,0 +1,6 @@
import type { WidgetProps } from "./widgets"
/** A clock — a stub until the clock widget is written. */
export function ClockWidget(_props: WidgetProps) {
return <p className="text-sm text-muted-foreground"></p>
}
@@ -0,0 +1,6 @@
import type { WidgetProps } from "./widgets"
/** A forecast — a stub until the forecast widget is written. */
export function ForecastWidget(_props: WidgetProps) {
return <p className="text-sm text-muted-foreground"></p>
}
@@ -0,0 +1,6 @@
import type { WidgetProps } from "./widgets"
/** An icon — a stub until the icon widget is written. */
export function IconWidget(_props: WidgetProps) {
return <p className="text-sm text-muted-foreground"></p>
}
@@ -0,0 +1,90 @@
import {
ArrowDown,
ArrowUp,
BatteryCharging,
Bed,
Check,
CircleCheck,
CloudDrizzle,
CloudFog,
CloudLightning,
CloudRain,
CloudSnow,
Cloudy,
DoorOpen,
Droplets,
Fan,
Flame,
House,
Lightbulb,
type LucideIcon,
Moon,
Plug,
Snowflake,
Sun,
SunSnow,
Thermometer,
ThermometerSun,
TriangleAlert,
Umbrella,
Wind,
Zap,
} from "lucide-react"
/**
* The icons a tile may be drawn with, by name.
*
* Curated rather than lucide's `dynamicIconImports`: the panel has to offer
* these in a list, and the dynamic map makes the bundler emit a lazy chunk per
* icon — some fifteen hundred of them — so a wall panel would fetch one request
* per tile over its LAN before it could draw anything. A few dozen weather,
* room and status glyphs cover what a dashboard says, and the map is cheap to
* extend.
*/
export const ICONS: Record<string, LucideIcon> = {
sun: Sun,
moon: Moon,
cloudy: Cloudy,
"cloud-rain": CloudRain,
"cloud-drizzle": CloudDrizzle,
"cloud-snow": CloudSnow,
"cloud-lightning": CloudLightning,
"cloud-fog": CloudFog,
wind: Wind,
umbrella: Umbrella,
snowflake: Snowflake,
droplets: Droplets,
thermometer: Thermometer,
"thermometer-sun": ThermometerSun,
flame: Flame,
fan: Fan,
"sun-snow": SunSnow,
"door-open": DoorOpen,
house: House,
bed: Bed,
lightbulb: Lightbulb,
plug: Plug,
zap: Zap,
"battery-charging": BatteryCharging,
"arrow-up": ArrowUp,
"arrow-down": ArrowDown,
"triangle-alert": TriangleAlert,
check: Check,
"circle-check": CircleCheck,
}
export const ICON_NAMES = Object.keys(ICONS)
/**
* What an icon may be tinted, by name.
*
* Literal classes so Tailwind's scanner sees them. No terracotta: that is the
* one affordance a view gets, and a panel is many tiles.
*/
export const ICON_COLORS: Record<string, string> = {
default: "text-foreground",
muted: "text-muted-foreground",
primary: "text-primary",
success: "text-status-success",
danger: "text-destructive",
}
+139 -13
View File
@@ -156,6 +156,49 @@ function ModePicker({
)
}
/** Which chrome an input wears, in the same segmented shape as the mode. */
function StylePicker({
value,
options,
onChange,
}: {
value: string
options: readonly (readonly [string, string])[]
onChange: (style: string) => void
}) {
return (
<fieldset
data-testid="widget-style"
className="flex w-fit items-center gap-1 rounded-full border border-border p-1"
>
<legend className="sr-only">How this control is drawn</legend>
{options.map(([style, label]) => (
<button
key={style}
type="button"
aria-pressed={value === style}
onClick={() => onChange(style)}
className={cn(
"rounded-full px-2.5 py-1 text-xs transition-colors",
value === style
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50",
)}
>
{label}
</button>
))}
</fieldset>
)
}
/** What a text field was meant to send: a bool, a number, or the text itself. */
function coerce(raw: string): unknown {
const asNumber = Number(raw)
if (raw === "true" || raw === "false") return raw === "true"
return raw !== "" && Number.isFinite(asNumber) ? asNumber : raw
}
/**
* What one widget shows or does.
*
@@ -183,6 +226,9 @@ export function WidgetPanel({
const series = seriesOf(widget)
const setSeries = (next: Series[]) => set({ series: next })
// Nothing wrote these until now, so a dropdown's choices were uneditable.
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
const setOptions = (next: typeof options) => set({ options: next })
const querying = cfg.source === "query"
// What this chart would refresh at with nothing configured. The viewer can
// pick another window on the widget, which moves it.
@@ -334,7 +380,8 @@ export function WidgetPanel({
</div>
)}
</div>
) : (
) : // A clock reads the wall; a picker would bind a message nothing reads.
widget.type === "clock" ? null : (
<MessagePicker
kind={widget.type}
value={str(cfg[isInput ? "target" : "message"])}
@@ -526,21 +573,100 @@ export function WidgetPanel({
<Input
value={str(cfg.value)}
placeholder="true"
onChange={(event) => {
const raw = event.target.value
const asNumber = Number(raw)
set({
value:
raw === "true" || raw === "false"
? raw === "true"
: raw !== "" && Number.isFinite(asNumber)
? asNumber
: raw,
})
}}
onChange={(event) => set({ value: coerce(event.target.value) })}
/>
</div>
) : null}
{widget.type === "dropdown" ? (
<div className="grid gap-2">
<span className={PANEL_SECTION}>Options</span>
{options.map((option, index) => (
<div
// Position is the only identity an option row has.
key={`option-${index}`}
className="flex items-end gap-1.5"
>
<Input
className="min-w-0 flex-1"
value={str(option.label)}
placeholder="Label"
aria-label="Option label"
onChange={(event) =>
setOptions(
options.map((other, at) =>
at === index
? { ...other, label: event.target.value }
: other,
),
)
}
/>
<Input
className="w-28"
value={str(option.value)}
placeholder="Value"
aria-label="Option value"
onChange={(event) =>
setOptions(
options.map((other, at) =>
at === index
? { ...other, value: coerce(event.target.value) }
: other,
),
)
}
/>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove option"
onClick={() =>
setOptions(options.filter((_, at) => at !== index))
}
>
<X />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
className="h-8 justify-self-start"
onClick={() => setOptions([...options, {}])}
data-testid="add-option"
>
<Plus />
Add option
</Button>
</div>
) : null}
{widget.type === "switch" || widget.type === "dropdown" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Style</Label>
{widget.type === "switch" ? (
<StylePicker
value={str(cfg.style) || "track"}
options={[
["track", "Track"],
["button", "Button"],
]}
onChange={(style) => set({ style })}
/>
) : (
<StylePicker
value={str(cfg.style) || "list"}
options={[
["list", "List"],
["segmented", "Segmented"],
]}
onChange={(style) => set({ style })}
/>
)}
</div>
) : null}
</div>
</SidePanel>
)
+86 -4
View File
@@ -19,7 +19,11 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { BarWidget } from "./BarWidget"
import { ChartWidget } from "./ChartWidget"
import { ClockWidget } from "./ClockWidget"
import { ForecastWidget } from "./ForecastWidget"
import { IconWidget } from "./IconWidget"
import { usePublishMessage } from "./queries"
/** Widget types that put a value into the graph rather than read one. */
@@ -50,6 +54,10 @@ export const WIDGET_DTYPES: Partial<Record<WidgetKind, string[]>> = {
switch: ["bool"],
agenda: ["list"],
notification: ["record"],
bar: ["float", "int"],
forecast: ["list"],
// An icon maps weather strings, bool hints and numbers alike, and a clock
// binds nothing at all, so neither has a row to be held to.
}
/** Whether a message of this payload type may drive this kind of widget. */
@@ -65,6 +73,10 @@ export const WIDGET_LABELS: Record<WidgetKind, string> = {
markdown: "Text",
agenda: "Agenda",
notification: "Notification",
bar: "Bar",
icon: "Icon",
forecast: "Forecast",
clock: "Clock",
button: "Button",
switch: "Switch",
slider: "Slider",
@@ -80,6 +92,10 @@ export const WIDGET_SIZES: Record<WidgetKind, { w: number; h: number }> = {
markdown: { w: 6, h: 2 },
agenda: { w: 4, h: 4 },
notification: { w: 4, h: 2 },
bar: { w: 4, h: 2 },
icon: { w: 2, h: 2 },
forecast: { w: 6, h: 2 },
clock: { w: 3, h: 2 },
button: { w: 3, h: 2 },
switch: { w: 3, h: 2 },
slider: { w: 4, h: 2 },
@@ -125,7 +141,8 @@ export const seriesOf = (widget: WidgetDef): Series[] =>
* fetching the message catalogue first.
*/
export function widgetIssue(widget: WidgetDef): string | null {
if (widget.type === "markdown") return null
// Neither draws a message: a clock reads the wall, markdown its own text.
if (widget.type === "markdown" || widget.type === "clock") return null
const cfg = config(widget)
if (widget.type === "chart" && cfg.source === "query") {
@@ -167,6 +184,13 @@ export function widgetIssue(widget: WidgetDef): string | null {
if (!acceptsDtype(widget.type, dtype)) {
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.
if (!acceptsDtype(widget.type, text(cfg.inner_dtype) || undefined)) {
return `${text(cfg.inner)} is a ${text(cfg.inner_dtype)}; a bar nests numbers.`
}
if (widget.type === "icon" && !(cfg.rules as unknown[] | undefined)?.length) {
return "This icon has nothing mapped yet."
}
return null
}
@@ -550,14 +574,33 @@ function ButtonWidget({ widget, dashboard }: WidgetProps) {
)
}
/**
* A bool, published and read back — a latch either way it is drawn.
*
* `style: "button"` is a control that stays in rather than a track; both name
* the state in words, because a fill alone does not say what it means.
*/
function SwitchWidget({ widget, dashboard }: WidgetProps) {
const cfg = config(widget)
const { target, live, send } = usePublish(widget, dashboard)
if (!target) return <Unbound />
return (
const on = live?.value === true
return cfg.style === "button" ? (
<Button
variant={on ? "default" : "secondary"}
className="w-full"
aria-pressed={on}
aria-label={widget.title || target}
onClick={() => send(!on)}
>
{on ? "On" : "Off"}
</Button>
) : (
<div className="flex items-center justify-between gap-2">
<span className="text-sm">{live?.value === true ? "On" : "Off"}</span>
<span className="text-sm">{on ? "On" : "Off"}</span>
<Switch
checked={live?.value === true}
checked={on}
aria-label={widget.title || target}
onCheckedChange={(checked) => send(checked)}
/>
@@ -653,12 +696,47 @@ function InputWidget({ widget, dashboard }: WidgetProps) {
)
}
/**
* One of N, published and read back.
*
* `style: "segmented"` shows every choice at once with the active one held —
* the same exclusive group, drawn for a panel that is looked at across a room.
*/
function DropdownWidget({ widget, dashboard }: WidgetProps) {
const cfg = config(widget)
const { target, live, send } = usePublish(widget, dashboard)
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
if (!target) return <Unbound />
if (cfg.style === "segmented") {
return (
// The one segmented shape: a single border pill, no dividers,
// transparent segments, bg-accent on the selected one.
<fieldset className="flex w-full items-center gap-1 rounded-full border border-border p-1">
<legend className="sr-only">{widget.title || target}</legend>
{options.map((option) => {
const selected = text(option.value) === text(live?.value)
return (
<button
key={text(option.value)}
type="button"
aria-pressed={selected}
onClick={() => send(option.value)}
className={cn(
"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"
: "text-muted-foreground hover:bg-accent/50",
)}
>
{option.label ?? text(option.value)}
</button>
)
})}
</fieldset>
)
}
return (
<div className="grid gap-1.5">
<Label className="sr-only">{widget.title || target}</Label>
@@ -700,6 +778,10 @@ const RENDERERS: Partial<
markdown: MarkdownWidget,
agenda: AgendaWidget,
notification: NotificationWidget,
bar: BarWidget,
icon: IconWidget,
forecast: ForecastWidget,
clock: ClockWidget,
button: ButtonWidget,
switch: SwitchWidget,
slider: SliderWidget,