diff --git a/web/src/components/config-form/ConfigFieldMessage.tsx b/web/src/components/config-form/ConfigFieldMessage.tsx
new file mode 100644
index 000000000..5c0a5c505
--- /dev/null
+++ b/web/src/components/config-form/ConfigFieldMessage.tsx
@@ -0,0 +1,48 @@
+import { useTranslation } from "react-i18next";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { LuInfo, LuTriangleAlert, LuCircleAlert } from "react-icons/lu";
+import type { MessageSeverity } from "./section-configs/types";
+
+const severityVariantMap: Record<
+ MessageSeverity,
+ "info" | "warning" | "destructive"
+> = {
+ info: "info",
+ warning: "warning",
+ error: "destructive",
+};
+
+function SeverityIcon({ severity }: { severity: string }) {
+ switch (severity) {
+ case "info":
+ return ;
+ case "warning":
+ return ;
+ case "error":
+ return ;
+ default:
+ return ;
+ }
+}
+
+type ConfigFieldMessageProps = {
+ messageKey: string;
+ severity: string;
+};
+
+export function ConfigFieldMessage({
+ messageKey,
+ severity,
+}: ConfigFieldMessageProps) {
+ const { t } = useTranslation("views/settings");
+
+ return (
+
+
+ {t(messageKey)}
+
+ );
+}
diff --git a/web/src/components/config-form/ConfigMessageBanner.tsx b/web/src/components/config-form/ConfigMessageBanner.tsx
new file mode 100644
index 000000000..15455705d
--- /dev/null
+++ b/web/src/components/config-form/ConfigMessageBanner.tsx
@@ -0,0 +1,52 @@
+import { useTranslation } from "react-i18next";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { LuInfo, LuTriangleAlert, LuCircleAlert } from "react-icons/lu";
+import type {
+ ConditionalMessage,
+ MessageSeverity,
+} from "./section-configs/types";
+
+const severityVariantMap: Record<
+ MessageSeverity,
+ "info" | "warning" | "destructive"
+> = {
+ info: "info",
+ warning: "warning",
+ error: "destructive",
+};
+
+function SeverityIcon({ severity }: { severity: MessageSeverity }) {
+ switch (severity) {
+ case "info":
+ return ;
+ case "warning":
+ return ;
+ case "error":
+ return ;
+ }
+}
+
+type ConfigMessageBannerProps = {
+ messages: ConditionalMessage[];
+};
+
+export function ConfigMessageBanner({ messages }: ConfigMessageBannerProps) {
+ const { t } = useTranslation("views/settings");
+
+ if (messages.length === 0) return null;
+
+ return (
+
+ {messages.map((msg) => (
+
+
+ {t(msg.messageKey)}
+
+ ))}
+
+ );
+}
diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx
index 8a35f4dff..3dbf1ee50 100644
--- a/web/src/components/config-form/sections/BaseSection.tsx
+++ b/web/src/components/config-form/sections/BaseSection.tsx
@@ -71,6 +71,13 @@ import {
} from "@/utils/configUtil";
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
import { useRestart } from "@/api/ws";
+import type {
+ ConditionalMessage,
+ FieldConditionalMessage,
+ MessageConditionContext,
+} from "../section-configs/types";
+import { useConfigMessages } from "@/hooks/use-config-messages";
+import { ConfigMessageBanner } from "../ConfigMessageBanner";
export interface SectionConfig {
/** Field ordering within the section */
@@ -100,6 +107,10 @@ export interface SectionConfig {
formData: unknown,
errors: FormValidation,
) => FormValidation;
+ /** Conditional messages displayed as banners above the section form */
+ messages?: ConditionalMessage[];
+ /** Conditional messages displayed inline with specific fields */
+ fieldMessages?: FieldConditionalMessage[];
}
export interface BaseSectionProps {
@@ -536,6 +547,57 @@ export function ConfigSection({
const currentFormData = pendingData || formData;
const effectiveBaselineFormData = baselineSnapshot;
+ // Build context for conditional messages
+ const messageContext = useMemo(() => {
+ if (!config || !currentFormData) return undefined;
+ return {
+ fullConfig: config,
+ fullCameraConfig:
+ effectiveLevel === "camera" && cameraName
+ ? config.cameras?.[cameraName]
+ : undefined,
+ level: effectiveLevel,
+ cameraName,
+ formData: currentFormData as ConfigSectionData,
+ };
+ }, [config, currentFormData, effectiveLevel, cameraName]);
+
+ const { activeMessages, activeFieldMessages } = useConfigMessages(
+ sectionConfig.messages,
+ sectionConfig.fieldMessages,
+ messageContext,
+ );
+
+ // Merge field-level conditional messages into uiSchema
+ const effectiveUiSchema = useMemo(() => {
+ if (activeFieldMessages.length === 0) return sectionConfig.uiSchema;
+ const merged = { ...(sectionConfig.uiSchema ?? {}) };
+ for (const msg of activeFieldMessages) {
+ const fieldKey = msg.field;
+ const existing = merged[fieldKey] as Record | undefined;
+ const existingMessages = ((existing?.["ui:messages"] as unknown[]) ??
+ []) as Array<{
+ key: string;
+ messageKey: string;
+ severity: string;
+ position?: string;
+ }>;
+ merged[fieldKey] = {
+ ...existing,
+ "ui:messages": [
+ ...existingMessages,
+ {
+ key: msg.key,
+ messageKey: msg.messageKey,
+ severity: msg.severity,
+ position: msg.position ?? "before",
+ },
+ ],
+ };
+ }
+ return merged;
+ }, [sectionConfig.uiSchema, activeFieldMessages]);
+
const currentOverrides = useMemo(() => {
if (!currentFormData || typeof currentFormData !== "object") {
return undefined;
@@ -874,6 +936,7 @@ export function ConfigSection({
const sectionContent = (
+
+ | undefined;
+ const beforeMessages = fieldMessageSpecs?.filter(
+ (m) => (m.position ?? "before") === "before",
+ );
+ const afterMessages = fieldMessageSpecs?.filter(
+ (m) => m.position === "after",
+ );
+ const beforeMessagesContent =
+ beforeMessages && beforeMessages.length > 0 ? (
+
+ {beforeMessages.map((m) => (
+
+ ))}
+
+ ) : null;
+ const afterMessagesContent =
+ afterMessages && afterMessages.length > 0 ? (
+
+ {afterMessages.map((m) => (
+
+ ))}
+
+ ) : null;
const WrapIfAdditionalTemplate = getTemplate(
"WrapIfAdditionalTemplate",
registry,
@@ -600,6 +641,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
>
{beforeContent}
+ {beforeMessagesContent}
{renderStandardLabel()}
{renderFieldLayout()}
@@ -607,6 +649,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
{errors}
{help}
+ {afterMessagesContent}
{afterContent}
diff --git a/web/src/hooks/use-config-messages.ts b/web/src/hooks/use-config-messages.ts
new file mode 100644
index 000000000..6c2b43853
--- /dev/null
+++ b/web/src/hooks/use-config-messages.ts
@@ -0,0 +1,27 @@
+import { useMemo } from "react";
+import type {
+ ConditionalMessage,
+ FieldConditionalMessage,
+ MessageConditionContext,
+} from "@/components/config-form/section-configs/types";
+
+export function useConfigMessages(
+ messages: ConditionalMessage[] | undefined,
+ fieldMessages: FieldConditionalMessage[] | undefined,
+ context: MessageConditionContext | undefined,
+): {
+ activeMessages: ConditionalMessage[];
+ activeFieldMessages: FieldConditionalMessage[];
+} {
+ const activeMessages = useMemo(() => {
+ if (!messages || !context) return [];
+ return messages.filter((msg) => msg.condition(context));
+ }, [messages, context]);
+
+ const activeFieldMessages = useMemo(() => {
+ if (!fieldMessages || !context) return [];
+ return fieldMessages.filter((msg) => msg.condition(context));
+ }, [fieldMessages, context]);
+
+ return { activeMessages, activeFieldMessages };
+}