diff --git a/frontend/src/components/Dashboard/IconWidget.tsx b/frontend/src/components/Dashboard/IconWidget.tsx
index 3afe09a..1c8ec7d 100644
--- a/frontend/src/components/Dashboard/IconWidget.tsx
+++ b/frontend/src/components/Dashboard/IconWidget.tsx
@@ -1,6 +1,70 @@
+import type { WidgetDef } from "@/client"
+import { useLiveValue } from "@/components/Flow/liveStore"
+import { cn } from "@/lib/utils"
+import { ICON_COLORS, ICONS } from "./icons"
import type { WidgetProps } from "./widgets"
-/** An icon — a stub until the icon widget is written. */
-export function IconWidget(_props: WidgetProps) {
- return
—
+/** One row of the mapping, as the document stores it. */
+type Rule = { at?: unknown; icon?: string; color?: string; label?: string }
+
+const config = (widget: WidgetDef) =>
+ (widget.config ?? {}) as Record
+
+const text = (value: unknown, fallback = "") =>
+ value === null || value === undefined ? fallback : String(value)
+
+/** What a mapped value was meant to be: 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
+}
+
+/**
+ * Whether a row claims this reading.
+ *
+ * Equality, plus `>=` when both sides are numbers: a ladder written from the
+ * top down then reads as thresholds, one field per row instead of a pair.
+ */
+const matches = (value: unknown, at: unknown) =>
+ value === at ||
+ (typeof value === "number" && typeof at === "number" && value >= at)
+
+/**
+ * A glyph picked by what a message says.
+ *
+ * Rows are checked top to bottom and the first match wins, so a weather
+ * condition, a boolean hint and a threshold ladder all fit the same widget.
+ */
+export function IconWidget({ widget }: WidgetProps) {
+ const cfg = config(widget)
+ const message = text(cfg.message)
+ const live = useLiveValue(message || undefined)
+
+ const value = live?.value
+ const rules = (cfg.rules ?? []) as Rule[]
+ const matched =
+ value === null || value === undefined
+ ? undefined
+ : rules.find((rule) => matches(value, coerce(text(rule.at))))
+ const Glyph = ICONS[matched?.icon ?? text(cfg.icon)] ?? null
+
+ // Unbound, unmatched, or pointed at a glyph that is not in the map.
+ if (!Glyph) return —
+
+ return (
+
+
+ {matched?.label ? (
+
+ {matched.label}
+
+ ) : null}
+
+ )
}