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:
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user