diff --git a/backend/app/flow/dashboards.py b/backend/app/flow/dashboards.py
index 3d68a61..cc9314d 100644
--- a/backend/app/flow/dashboards.py
+++ b/backend/app/flow/dashboards.py
@@ -42,6 +42,10 @@ WidgetType = Literal[
"markdown",
"agenda",
"notification",
+ "bar",
+ "icon",
+ "forecast",
+ "clock",
# Input
"button",
"switch",
@@ -66,6 +70,10 @@ WIDGET_DTYPES: dict[str, set[str]] = {
"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.
}
@@ -124,7 +132,8 @@ class WidgetDef(BaseModel):
if series.get("message")
]
name = self.config.get("message")
- return [str(name)] if name else []
+ inner = self.config.get("inner") # only a bar nests a second reading
+ return [str(value) for value in (name, inner) if value]
@property
def target(self) -> str:
@@ -161,7 +170,7 @@ class WidgetDef(BaseModel):
str(series.get("dtype") or "")
for series in self.config.get("series") or []
]
- return [str(self.config.get("dtype") or "")]
+ return [str(self.config.get(key) or "") for key in ("dtype", "inner_dtype")]
@model_validator(mode="after")
def _check_binding(self) -> WidgetDef:
diff --git a/backend/tests/flow/test_dashboards.py b/backend/tests/flow/test_dashboards.py
index 1e0aa07..4c9f07f 100644
--- a/backend/tests/flow/test_dashboards.py
+++ b/backend/tests/flow/test_dashboards.py
@@ -213,6 +213,7 @@ def test_a_widget_refuses_a_dtype_it_cannot_carry():
def test_the_structured_widgets_bind_their_shapes():
WidgetDef(id="a", type="agenda", config={"message": "a.b", "dtype": "list"})
WidgetDef(id="n", type="notification", config={"message": "a.b", "dtype": "record"})
+ WidgetDef(id="f", type="forecast", config={"message": "a.b", "dtype": "list"})
with pytest.raises(ValueError):
WidgetDef(id="a", type="agenda", config={"message": "a.b", "dtype": "json"})
@@ -220,6 +221,41 @@ def test_the_structured_widgets_bind_their_shapes():
WidgetDef(
id="n", type="notification", config={"message": "a.b", "dtype": "list"}
)
+ with pytest.raises(ValueError):
+ WidgetDef(id="f", type="forecast", config={"message": "a.b", "dtype": "json"})
+
+
+def test_a_bar_nests_a_second_number():
+ WidgetDef(
+ id="b",
+ type="bar",
+ config={
+ "message": "a.in",
+ "dtype": "float",
+ "inner": "a.pv",
+ "inner_dtype": "int",
+ },
+ )
+
+ with pytest.raises(ValueError):
+ WidgetDef(
+ id="b",
+ type="bar",
+ config={"message": "a.in", "dtype": "float", "inner_dtype": "bool"},
+ )
+
+
+def test_a_bar_is_drawn_on_both_readings_it_nests():
+ widget = WidgetDef(id="b", type="bar", config={"message": "a.in", "inner": "a.pv"})
+
+ assert widget.messages == ["a.in", "a.pv"]
+
+
+def test_a_clock_reads_nothing_and_publishes_nothing():
+ widget = WidgetDef(id="c", type="clock", config={"format": "24h"})
+
+ assert widget.messages == []
+ assert widget.target == ""
def test_a_querying_chart_asks_with_a_record_and_draws_a_series():
diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts
index dad9fc0..09df90d 100644
--- a/frontend/src/client/schemas.gen.ts
+++ b/frontend/src/client/schemas.gen.ts
@@ -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: {
diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts
index ba35a1d..78cd8a2 100644
--- a/frontend/src/client/types.gen.ts
+++ b/frontend/src/client/types.gen.ts
@@ -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;
diff --git a/frontend/src/components/Dashboard/BarWidget.tsx b/frontend/src/components/Dashboard/BarWidget.tsx
new file mode 100644
index 0000000..be8c23f
--- /dev/null
+++ b/frontend/src/components/Dashboard/BarWidget.tsx
@@ -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
—
+}
diff --git a/frontend/src/components/Dashboard/ClockWidget.tsx b/frontend/src/components/Dashboard/ClockWidget.tsx
new file mode 100644
index 0000000..bef1b89
--- /dev/null
+++ b/frontend/src/components/Dashboard/ClockWidget.tsx
@@ -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
—
+}
diff --git a/frontend/src/components/Dashboard/ForecastWidget.tsx b/frontend/src/components/Dashboard/ForecastWidget.tsx
new file mode 100644
index 0000000..62b8baa
--- /dev/null
+++ b/frontend/src/components/Dashboard/ForecastWidget.tsx
@@ -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
—
+}
diff --git a/frontend/src/components/Dashboard/IconWidget.tsx b/frontend/src/components/Dashboard/IconWidget.tsx
new file mode 100644
index 0000000..3afe09a
--- /dev/null
+++ b/frontend/src/components/Dashboard/IconWidget.tsx
@@ -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
—
+}
diff --git a/frontend/src/components/Dashboard/icons.ts b/frontend/src/components/Dashboard/icons.ts
new file mode 100644
index 0000000..ad27279
--- /dev/null
+++ b/frontend/src/components/Dashboard/icons.ts
@@ -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 = {
+ 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 = {
+ default: "text-foreground",
+ muted: "text-muted-foreground",
+ primary: "text-primary",
+ success: "text-status-success",
+ danger: "text-destructive",
+}
diff --git a/frontend/src/components/Dashboard/panels.tsx b/frontend/src/components/Dashboard/panels.tsx
index 05aa623..0e7e592 100644
--- a/frontend/src/components/Dashboard/panels.tsx
+++ b/frontend/src/components/Dashboard/panels.tsx
@@ -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 (
+
+ )
+}
+
+/** 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({
)}
- ) : (
+ ) : // A clock reads the wall; a picker would bind a message nothing reads.
+ widget.type === "clock" ? null : (
{
- 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) })}
/>
) : null}
+
+ {widget.type === "dropdown" ? (
+
+ ) : null}
)
diff --git a/frontend/src/components/Dashboard/widgets.tsx b/frontend/src/components/Dashboard/widgets.tsx
index 12fa97a..d63d979 100644
--- a/frontend/src/components/Dashboard/widgets.tsx
+++ b/frontend/src/components/Dashboard/widgets.tsx
@@ -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> = {
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 = {
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 = {
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
- return (
+
+ const on = live?.value === true
+ return cfg.style === "button" ? (
+
+ ) : (
- {live?.value === true ? "On" : "Off"}
+ {on ? "On" : "Off"} 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
+ if (cfg.style === "segmented") {
+ return (
+ // The one segmented shape: a single border pill, no dividers,
+ // transparent segments, bg-accent on the selected one.
+
+ )
+ }
+
return (