diff --git a/frontend/src/components/Dashboard/ForecastWidget.tsx b/frontend/src/components/Dashboard/ForecastWidget.tsx
index ddab05b..ef750a3 100644
--- a/frontend/src/components/Dashboard/ForecastWidget.tsx
+++ b/frontend/src/components/Dashboard/ForecastWidget.tsx
@@ -1,6 +1,6 @@
import { cn } from "@/lib/utils"
import { useBoundValue } from "./dataContext"
-import { ICON_COLORS, ICONS } from "./icons"
+import { resolveIcon, resolveIconColor } from "./icons"
import { config } from "./ui/core/config"
import type { WidgetProps } from "./widgets"
@@ -41,7 +41,7 @@ export function ForecastWidget({ widget }: WidgetProps) {
return (
{items.map((item, index) => {
- const Glyph = ICONS[item.icon ?? ""] ?? null
+ const Glyph = resolveIcon(item.icon ?? "")
return (
) : null}
diff --git a/frontend/src/components/Dashboard/IconWidget.tsx b/frontend/src/components/Dashboard/IconWidget.tsx
index d634251..03822ca 100644
--- a/frontend/src/components/Dashboard/IconWidget.tsx
+++ b/frontend/src/components/Dashboard/IconWidget.tsx
@@ -1,6 +1,6 @@
import { cn } from "@/lib/utils"
import { useBoundValue } from "./dataContext"
-import { ICON_COLORS, ICONS } from "./icons"
+import { resolveIcon, resolveIconColor } from "./icons"
import { config, text } from "./ui/core/config"
import type { WidgetProps } from "./widgets"
@@ -41,15 +41,19 @@ export function IconWidget({ widget }: WidgetProps) {
value === null || value === undefined
? undefined
: rules.find((rule) => matches(value, coerce(text(rule.at))))
- const Glyph = ICONS[matched?.icon ?? text(cfg.icon)] ?? null
+ const Glyph = resolveIcon(matched?.icon ?? text(cfg.icon))
- // Unbound, unmatched, or pointed at a glyph that is not in the map.
+ // Unbound or unmatched — a named-but-unknown glyph resolves to the
+ // placeholder above rather than falling through to here.
if (!Glyph) return
—
return (
= {
export const ICON_NAMES = Object.keys(ICONS)
+/**
+ * A tile's glyph, or the neutral placeholder for a name nothing maps to.
+ *
+ * A flow author names an icon by guess as often as by picking one from the
+ * panel, so a name outside {@link ICONS} is a typo, not "no icon" — unlike an
+ * empty name, which stays undrawn rather than warning. The warning is what
+ * makes the typo findable; the placeholder is what makes it visible.
+ */
+export function resolveIcon(name: string): LucideIcon | null {
+ if (!name) return null
+ const icon = ICONS[name]
+ if (icon) return icon
+ warnOnce(
+ `Dashboard: unknown icon "${name}", expected one of: ${ICON_NAMES.join(", ")}`,
+ )
+ return CircleHelp
+}
+
/**
* What an icon may be tinted, by name.
*
@@ -94,3 +113,28 @@ export const ICON_COLORS: Record = {
success: "text-status-success",
danger: "text-destructive",
}
+
+/** A tile's tint, or the widget's own default for a name nothing maps to. */
+export function resolveIconColor(name: string): string {
+ const color = ICON_COLORS[name]
+ if (color) return color
+ if (name) {
+ warnOnce(
+ `Dashboard: unknown icon color "${name}", expected one of: ${Object.keys(ICON_COLORS).join(", ")}`,
+ )
+ }
+ return ICON_COLORS.default
+}
+
+/**
+ * Both lookups sit in a render, and a bound widget renders as often as its
+ * value arrives — so a typo would otherwise be a warning a second, for as long
+ * as the panel is open.
+ */
+const warned = new Set()
+
+function warnOnce(message: string) {
+ if (warned.has(message)) return
+ warned.add(message)
+ console.warn(message)
+}