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) +}