) : isCompareView ? (
+ ) : isErrorsView ? (
+
) : (
)}
diff --git a/packages/ui/spa/components/Field.tsx b/packages/ui/spa/components/Field.tsx
index d4986ff7e..27d07c98d 100644
--- a/packages/ui/spa/components/Field.tsx
+++ b/packages/ui/spa/components/Field.tsx
@@ -1,5 +1,6 @@
import { Json, SerializedSchema, SourcePath } from "@valbuild/core";
import { Label } from "./Label";
+import { fromCamelToTitleCase } from "../utils/prettifyText";
import classNames from "classnames";
import { ChevronDown, ChevronsDown } from "lucide-react";
import { Checkbox } from "./designSystem/checkbox";
@@ -14,8 +15,10 @@ import {
} from "./designSystem/accordion";
import { FieldValidationError } from "./FieldValidationError";
import { FieldPatchAuthorsSection } from "./FieldPatchAuthorsSection";
-import { ShallowSource } from "./ValFieldProvider";
+import { ShallowSource, useAllSources, useSchemas } from "./ValFieldProvider";
import { useFieldState } from "./useFieldState";
+import { useNavigation } from "./ValRouter";
+import { getNavPathFromAll } from "./getNavPath";
export function Field({
label,
@@ -29,6 +32,7 @@ export function Field({
source: sourceProp,
schema: schemaProp,
initialExpanded,
+ errorDisplay = "default",
}: {
label?: string | React.ReactNode;
children: React.ReactNode;
@@ -41,6 +45,7 @@ export function Field({
source?: Json | null;
schema?: SerializedSchema;
initialExpanded?: boolean;
+ errorDisplay?: "default" | "compact" | "none";
}) {
if ((sourceProp !== undefined) !== (schemaProp !== undefined)) {
throw new Error(
@@ -67,14 +72,29 @@ export function Field({
isNullable,
} = useFieldState(path, type, overrides, initialExpanded);
const effectiveReadonly = readonly || hasOverrides;
+ const { navigate } = useNavigation();
+ const schemas = useSchemas();
+ const allSources = useAllSources();
+ const handleLabelNavigate = () => {
+ const schemasData = schemas.status === "success" ? schemas.data : undefined;
+ const navPath = getNavPathFromAll(path, allSources, schemasData);
+ const target = navPath ?? path;
+ navigate(target, {
+ scrollToPath: target !== path ? path : undefined,
+ });
+ };
+ const labelClickable = errorDisplay === "compact";
return (
0,
+ "border-bg-warning-secondary":
+ !hasOverrides &&
+ errorDisplay === "default" &&
+ validationErrors.length > 0,
})}
>
)}
- {typeof label === "string" &&
{label} }
+ {typeof label === "string" &&
+ (labelClickable ? (
+
+ {fromCamelToTitleCase(label)}
+
+ ) : (
+
{label}
+ ))}
{label && typeof label !== "string" && label}
)}
- {!hasOverrides && validationErrors.length > 0 && (
-
-
-
- )}
+ {!hasOverrides &&
+ errorDisplay === "default" &&
+ validationErrors.length > 0 && (
+
+
+
+ )}
);
}
diff --git a/packages/ui/spa/components/FieldErrorList.tsx b/packages/ui/spa/components/FieldErrorList.tsx
new file mode 100644
index 000000000..c34ef7208
--- /dev/null
+++ b/packages/ui/spa/components/FieldErrorList.tsx
@@ -0,0 +1,52 @@
+import { CircleAlert } from "lucide-react";
+import { ValidationError } from "@valbuild/core";
+import classNames from "classnames";
+
+/**
+ * Shared error-list rendering for fields. Used by:
+ * - `FieldValidationError` (inline below an editor field)
+ * - `ValidationErrors` (the dedicated `/val/errors` page)
+ *
+ * The look is deliberately the amber/warning palette rather than error-red:
+ * it makes the messages visible at a glance without reading as "the system is
+ * broken". A 2px amber rail on the left groups all messages for one field
+ * together; each message is prefixed with a small `CircleAlert` icon.
+ *
+ * Render nothing if there are no errors. Callers don't need to gate.
+ */
+export function FieldErrorList({
+ validationErrors,
+ ariaLabel = "Validation errors for this field",
+ className,
+}: {
+ validationErrors: ValidationError[];
+ ariaLabel?: string;
+ className?: string;
+}) {
+ if (validationErrors.length === 0) {
+ return null;
+ }
+ return (
+
+
+
+ {validationErrors.map((err, i) => (
+
+
+ {err.message}
+
+ ))}
+
+
+ );
+}
diff --git a/packages/ui/spa/components/FieldPatchAuthors.tsx b/packages/ui/spa/components/FieldPatchAuthors.tsx
index cc04dca24..cfc073291 100644
--- a/packages/ui/spa/components/FieldPatchAuthors.tsx
+++ b/packages/ui/spa/components/FieldPatchAuthors.tsx
@@ -276,7 +276,7 @@ export function FieldPatchAuthors({
const onNavigateToCompare =
navigable && sourcePath
- ? () => navigate("/val/compare", { scrollToId: `compare-${sourcePath}` })
+ ? () => navigate("/val/compare", { scrollToPath: sourcePath })
: undefined;
if (!onNavigateToCompare) {
diff --git a/packages/ui/spa/components/FieldValidationError.tsx b/packages/ui/spa/components/FieldValidationError.tsx
index d79c38038..c3353fa0f 100644
--- a/packages/ui/spa/components/FieldValidationError.tsx
+++ b/packages/ui/spa/components/FieldValidationError.tsx
@@ -1,173 +1,100 @@
-import { ChevronDown, TriangleAlert } from "lucide-react";
+import { SourcePath, ValidationError } from "@valbuild/core";
+import classNames from "classnames";
+import { AlertTriangle, Loader2 } from "lucide-react";
+import { FieldErrorList } from "./FieldErrorList";
import {
- Accordion,
- AccordionContent,
- AccordionItem,
- AccordionTrigger,
-} from "./designSystem/accordion";
-import { cn } from "./designSystem/cn";
-import { ValidationError } from "@valbuild/core";
-import { useState, useRef, useEffect } from "react";
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "./designSystem/tooltip";
+import {
+ useAllSources,
+ useLoadingStatus,
+ useSchemas,
+} from "./ValFieldProvider";
+import { useAllValidationErrors } from "./ValErrorProvider";
+import { useNavigation } from "./ValRouter";
+import { getNavPathFromAll } from "./getNavPath";
+/**
+ * Inline error display rendered below a field in the editor.
+ *
+ * The visual style is owned by `FieldErrorList` and is shared with the
+ * `/val/errors` page so the same field reads identically in both views.
+ */
export function FieldValidationError({
validationErrors,
}: {
validationErrors: ValidationError[];
}) {
+ if (validationErrors.length === 0) {
+ return null;
+ }
return (
-
- {validationErrors.length > 1 && (
-
-
-
-
-
- {validationErrors.length} validation error
- {validationErrors.length > 1 ? "s" : ""}
-
-
-
-
-
-
-
-
- {validationErrors.map((error, i) => (
-
- {error.message}
-
- ))}
-
-
-
-
- )}
-
- {validationErrors.length === 1 && validationErrors[0] && (
-
- )}
+
+
);
}
-function SingleValidationError({
- validationError,
-}: {
- validationError: ValidationError;
-}) {
- const [open, setOpen] = useState(false);
- const [showChevron, setShowChevron] = useState(false);
- const textRef = useRef
(null);
- const containerRef = useRef(null);
-
- useEffect(() => {
- const checkTextOverflow = () => {
- if (textRef.current) {
- // Create a test element with the same content and styling but without line-clamp
- const testElement = document.createElement("div");
- const computedStyle = window.getComputedStyle(textRef.current);
-
- // Copy relevant styles but remove line clamp
- testElement.style.cssText = computedStyle.cssText;
- testElement.style.position = "absolute";
- testElement.style.visibility = "hidden";
- testElement.style.left = "-9999px";
- testElement.style.width = computedStyle.width;
- testElement.style.webkitLineClamp = "unset";
- testElement.style.overflow = "visible";
- testElement.style.display = "block";
- testElement.textContent = validationError.message;
-
- document.body.appendChild(testElement);
- const fullHeight = testElement.getBoundingClientRect().height;
- document.body.removeChild(testElement);
-
- // Get the current height of the clamped element
- const clampedHeight = textRef.current.getBoundingClientRect().height;
-
- // Show chevron if the full text is taller than the clamped version
- setShowChevron(fullHeight > clampedHeight + 1); // Adding 1px tolerance for rounding
+export function FieldValidationErrorCompact({ path }: { path: SourcePath }) {
+ const { navigate } = useNavigation();
+ const schemas = useSchemas();
+ const allSources = useAllSources();
+ const validationErrors = useAllValidationErrors();
+ const loadingStatus = useLoadingStatus();
+ const isLoading = loadingStatus === "loading";
+ const messages: string[] = [];
+ if (validationErrors) {
+ for (const errorPath in validationErrors) {
+ if (errorPath.startsWith(path)) {
+ for (const err of validationErrors[errorPath as SourcePath] ?? []) {
+ messages.push(err.message);
+ }
}
- };
-
- // Use a small delay to ensure DOM is ready and styles are applied
- const timeoutId = setTimeout(checkTextOverflow, 50);
-
- window.addEventListener("resize", checkTextOverflow);
- return () => {
- clearTimeout(timeoutId);
- window.removeEventListener("resize", checkTextOverflow);
- };
- }, [validationError.message]);
+ }
+ }
+ if (messages.length === 0) return
;
return (
-
-
- {validationError.message}
-
- {!showChevron && (
-
-
-
- )}
- {showChevron && (
+
+
setOpen(!open)}
+ onClick={() => {
+ const schemasData =
+ schemas.status === "success" ? schemas.data : undefined;
+ const navPath = getNavPathFromAll(path, allSources, schemasData);
+ navigate(navPath ?? path, {
+ scrollToPath: path,
+ });
+ }}
+ className={classNames(
+ "inline-flex items-center justify-center size-6 rounded bg-bg-warning-secondary text-fg-warning-secondary hover:bg-bg-warning-secondary-hover transition-colors",
+ { "opacity-80": isLoading },
+ )}
+ aria-label="Go to validation error"
>
-
-
-
-
+ {isLoading ? (
+
+ ) : (
+
+ )}
- )}
-
+
+
+ {messages.length === 1 ? (
+ {messages[0]}
+ ) : (
+
+ {messages.map((msg, i) => (
+ {msg}
+ ))}
+
+ )}
+
+
);
}
diff --git a/packages/ui/spa/components/FileGallery/FilePropertiesModal.tsx b/packages/ui/spa/components/FileGallery/FilePropertiesModal.tsx
index afdc2203d..90d217671 100644
--- a/packages/ui/spa/components/FileGallery/FilePropertiesModal.tsx
+++ b/packages/ui/spa/components/FileGallery/FilePropertiesModal.tsx
@@ -233,7 +233,7 @@ export function FilePropertiesModal({
type="button"
onClick={() => {
navigate("/val/compare", {
- scrollToId: `compare-${file.sourcePath}`,
+ scrollToPath: file.sourcePath,
});
}}
className="inline-flex items-center gap-2 rounded-md bg-bg-secondary px-3 py-2 text-sm font-medium text-fg-primary transition-colors hover:bg-bg-tertiary"
diff --git a/packages/ui/spa/components/InlineField.tsx b/packages/ui/spa/components/InlineField.tsx
index 2bcea8fe9..54230c39d 100644
--- a/packages/ui/spa/components/InlineField.tsx
+++ b/packages/ui/spa/components/InlineField.tsx
@@ -63,7 +63,7 @@ export function InlineField({
return (
0,
})}
>
diff --git a/packages/ui/spa/components/Module.tsx b/packages/ui/spa/components/Module.tsx
index 6e255031e..f711a1224 100644
--- a/packages/ui/spa/components/Module.tsx
+++ b/packages/ui/spa/components/Module.tsx
@@ -158,7 +158,7 @@ export function Module({
)}
0,
})}
>
@@ -207,7 +207,7 @@ export function Module({
)}
0,
})}
>
diff --git a/packages/ui/spa/components/SortableList.tsx b/packages/ui/spa/components/SortableList.tsx
index dcfea76cb..7713cf7d7 100644
--- a/packages/ui/spa/components/SortableList.tsx
+++ b/packages/ui/spa/components/SortableList.tsx
@@ -260,7 +260,7 @@ export function SortableItemRow({
{!render && schema?.item?.type === "string" && (
diff --git a/packages/ui/spa/components/ToolsMenu.tsx b/packages/ui/spa/components/ToolsMenu.tsx
index ec306238a..009e9d93e 100644
--- a/packages/ui/spa/components/ToolsMenu.tsx
+++ b/packages/ui/spa/components/ToolsMenu.tsx
@@ -14,12 +14,15 @@ import {
useValConfig,
} from "./ValFieldProvider";
import { ScrollArea } from "./designSystem/scroll-area";
+import { PatchErrorsDisplay, TransientErrorsDisplay } from "./DraftChanges";
import {
- PatchErrorsDisplay,
- ValidationErrorsDisplay,
- TransientErrorsDisplay,
-} from "./DraftChanges";
-import { GitCompareArrows, Globe, Loader2, PanelsTopLeft } from "lucide-react";
+ AlertTriangle,
+ CheckCircle2,
+ GitCompareArrows,
+ Globe,
+ Loader2,
+ PanelsTopLeft,
+} from "lucide-react";
import { Button } from "./designSystem/button";
import { urlOf } from "@valbuild/shared/internal";
import { Fragment, useMemo, useRef, useState } from "react";
@@ -29,7 +32,7 @@ import type { AIChatHandle } from "./AIChat";
import { useAI } from "../hooks/useAI";
import { Internal, ModuleFilePath, SourcePath } from "@valbuild/core";
import { prettifyFilename } from "../utils/prettifyFilename";
-import { useNavigation } from "./ValRouter";
+import { useNavigation, VAL_ERRORS_ROUTE } from "./ValRouter";
import { PublishButton } from "./PublishButton";
import { Checkbox } from "./designSystem/checkbox";
import {
@@ -46,17 +49,19 @@ export function ToolsMenu() {
const mode = useValMode();
const config = useValConfig();
const isChatEnabled = config?.ai?.chat?.experimental?.enable === true;
- const [errorModules, sumValidationErrors] = useMemo(() => {
+ const [errorModules, fieldsWithErrors, errorPaths] = useMemo(() => {
const modulesWithErrors = new Set
();
- let sumValidationErrors = 0;
+ const errorPaths: SourcePath[] = [];
+ let fieldsWithErrors = 0;
for (const sourcePath in validationErrors) {
const [moduleFilePath] = Internal.splitModuleFilePathAndModulePath(
sourcePath as SourcePath,
);
modulesWithErrors.add(moduleFilePath);
- sumValidationErrors += 1;
+ errorPaths.push(sourcePath as SourcePath);
+ fieldsWithErrors += 1;
}
- return [Array.from(modulesWithErrors).sort(), sumValidationErrors];
+ return [Array.from(modulesWithErrors).sort(), fieldsWithErrors, errorPaths];
}, [validationErrors]);
const currentPatchIds = useCurrentPatchIds();
const committedPatchIds = useCommittedPatches();
@@ -96,14 +101,13 @@ export function ToolsMenu() {
{globalErrors &&
globalErrors.length > 0 &&
- globalErrors.length !== sumValidationErrors && (
+ globalErrors.length !== fieldsWithErrors && (
Cannot {mode === "fs" ? "save" : "publish"} now. Found{" "}
{globalErrors?.length} errors in all.{" "}
- {globalErrors.length - sumValidationErrors} were
- non-validation errors. A developer might need to fix these
- issues.
+ {globalErrors.length - fieldsWithErrors} were non-validation
+ errors. A developer might need to fix these issues.
@@ -114,7 +118,7 @@ export function ToolsMenu() {
)}
- {globalErrors?.length === sumValidationErrors &&
+ {globalErrors?.length === fieldsWithErrors &&
errorModules.length > 0 && (
@@ -131,14 +135,14 @@ export function ToolsMenu() {
)}
-
- {loadingStatus !== "not-asked" && (
-
- )}
+
{(mode === "http" || mode === "fs") && isChatEnabled && (
@@ -210,6 +214,89 @@ function ShortenedErrorMessage({ error }: { error: string }) {
);
}
+function ValidationAndCompareRow({
+ fieldsWithErrors,
+ errorPaths,
+ showCompare,
+ pendingChanges,
+ isLoading,
+}: {
+ fieldsWithErrors: number;
+ errorPaths: SourcePath[];
+ showCompare: boolean;
+ pendingChanges: number;
+ isLoading: boolean;
+}) {
+ return (
+
+
+ {showCompare && (
+
+ )}
+
+ );
+}
+
+function ValidationStatusPill({
+ fieldsWithErrors,
+ errorPaths,
+}: {
+ fieldsWithErrors: number;
+ errorPaths: SourcePath[];
+}) {
+ const { navigate } = useNavigation();
+ const pillBase =
+ "inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium whitespace-nowrap";
+ if (fieldsWithErrors === 0) {
+ return (
+
+
+
+
+ No errors
+
+
+
+ No validation errors
+
+
+ );
+ }
+ const label =
+ fieldsWithErrors === 1 ? "1 field" : `${fieldsWithErrors} fields`;
+ return (
+
+
+
+ navigate(VAL_ERRORS_ROUTE, { errorFields: errorPaths })
+ }
+ >
+
+ {label}
+
+
+
+ Open validation errors
+
+
+ );
+}
+
function CompareButton({
pendingChanges,
isLoading,
@@ -219,22 +306,17 @@ function CompareButton({
}) {
const { navigate } = useNavigation();
return (
-
-
navigate("/val/compare")}
- >
-
- Compare
- {isLoading && (
-
-
-
- )}
-
-
+
navigate("/val/compare")}
+ >
+
+ Compare
+ {isLoading && }
+
);
}
diff --git a/packages/ui/spa/components/ValPath.tsx b/packages/ui/spa/components/ValPath.tsx
index ed829b7eb..bbe91e867 100644
--- a/packages/ui/spa/components/ValPath.tsx
+++ b/packages/ui/spa/components/ValPath.tsx
@@ -129,7 +129,7 @@ export function ValPath({
if (navPath) {
return {
navPath,
- scrollToId: sourcePath,
+ scrollToPath: sourcePath,
};
} else {
console.debug(
@@ -154,7 +154,11 @@ export function ValPath({
void;
currentSourcePath: SourcePath;
isCompareView: boolean;
+ isErrorsView: boolean;
+ errorFields: SourcePath[];
};
const ValRouterContext = React.createContext(
new Proxy(
@@ -35,33 +43,32 @@ const ValRouterContext = React.createContext(
const VAL_CONTENT_VIEW_ROUTE = "/val/~"; // TODO: make route configurable
-function findFieldWrapper(element: HTMLElement): HTMLElement {
- let el: HTMLElement | null = element.parentElement;
- while (el) {
- if (el.id === "val-content-area") break;
- if (
- el.classList.contains("border") &&
- el.classList.contains("rounded-lg")
- ) {
- return el;
- }
- el = el.parentElement;
+const STUDIO_PATH_ATTR = "data-val-studio-path";
+
+function findStudioPathTarget(
+ root: ShadowRoot,
+ path: string,
+): HTMLElement | null {
+ const candidates = Array.from(
+ root.querySelectorAll(`[${STUDIO_PATH_ATTR}]`),
+ );
+ for (const el of candidates) {
+ if (el.getAttribute(STUDIO_PATH_ATTR) === path) return el;
}
- return element;
+ return null;
}
function doScroll(shadowRoot: ShadowRoot, element: HTMLElement) {
- const target = findFieldWrapper(element);
shadowRoot.getElementById("val-content-area")?.scrollTo({
- top: Math.max(0, target.offsetTop - 16),
+ top: Math.max(0, element.offsetTop - 16),
behavior: "smooth",
});
- target.classList.remove("val-scroll-highlight");
- void target.offsetWidth;
- target.classList.add("val-scroll-highlight");
- target.addEventListener(
+ element.classList.remove("val-scroll-highlight");
+ void element.offsetWidth;
+ element.classList.add("val-scroll-highlight");
+ element.addEventListener(
"animationend",
- () => target.classList.remove("val-scroll-highlight"),
+ () => element.classList.remove("val-scroll-highlight"),
{ once: true },
);
}
@@ -79,6 +86,8 @@ export function ValRouter({
const [ready, setReady] = useState(false);
const [currentSourcePath, setSourcePath] = useState("" as SourcePath);
const [isCompareView, setIsCompareView] = useState(false);
+ const [isErrorsView, setIsErrorsView] = useState(false);
+ const [errorFields, setErrorFields] = useState([]);
const historyState = useRef([]);
useEffect(() => {
const listener = () => {
@@ -87,11 +96,30 @@ export function ValRouter({
location.pathname === VAL_COMPARE_ROUTE + "/"
) {
setIsCompareView(true);
+ setIsErrorsView(false);
+ setErrorFields([]);
+ setSourcePath("" as SourcePath);
+ setReady(true);
+ return;
+ }
+ if (
+ location.pathname === VAL_ERRORS_ROUTE ||
+ location.pathname === VAL_ERRORS_ROUTE + "/"
+ ) {
+ setIsErrorsView(true);
+ setIsCompareView(false);
+ setErrorFields(
+ new URLSearchParams(location.search).getAll(
+ "error-field",
+ ) as SourcePath[],
+ );
setSourcePath("" as SourcePath);
setReady(true);
return;
}
setIsCompareView(false);
+ setIsErrorsView(false);
+ setErrorFields([]);
const valPathIndex = location.pathname.indexOf(VAL_CONTENT_VIEW_ROUTE);
if (valPathIndex > -1) {
const modulePath = new URLSearchParams(location.search).get("p");
@@ -112,7 +140,7 @@ export function ValRouter({
}
}, 50);
} else if (location.hash) {
- const scrollToId = decodeURIComponent(location.hash.slice(1));
+ const scrollToPath = decodeURIComponent(location.hash.slice(1));
// remove hash:
window.history.replaceState(
null,
@@ -121,10 +149,12 @@ export function ValRouter({
);
let retriesLeft = 100;
const execScroll = () => {
- if (scrollToId) {
+ if (scrollToPath) {
const shadowRoot =
document.getElementById("val-shadow-root")?.shadowRoot;
- const element = shadowRoot?.getElementById(scrollToId);
+ const element = shadowRoot
+ ? findStudioPathTarget(shadowRoot, scrollToPath)
+ : null;
if (element && shadowRoot) {
doScroll(shadowRoot, element);
} else if (retriesLeft > 0) {
@@ -153,25 +183,47 @@ export function ValRouter({
}, []);
const navigate = useCallback(
(
- path: SourcePath | ModuleFilePath | typeof VAL_COMPARE_ROUTE,
- params?: { scrollToId?: string; replace?: true },
+ path:
+ | SourcePath
+ | ModuleFilePath
+ | typeof VAL_COMPARE_ROUTE
+ | typeof VAL_ERRORS_ROUTE,
+ params?: {
+ scrollToPath?: SourcePath | ModuleFilePath;
+ replace?: true;
+ errorFields?: SourcePath[];
+ },
) => {
const isCompare = path === VAL_COMPARE_ROUTE;
+ const isErrors = path === VAL_ERRORS_ROUTE;
+ const errorFieldsQuery =
+ isErrors && params?.errorFields && params.errorFields.length > 0
+ ? "?" +
+ params.errorFields
+ .map((p) => `error-field=${encodeURIComponent(p)}`)
+ .join("&")
+ : "";
const navigateTo = isCompare
? VAL_COMPARE_ROUTE
- : `${VAL_CONTENT_VIEW_ROUTE}${path}`;
+ : isErrors
+ ? VAL_ERRORS_ROUTE + errorFieldsQuery
+ : `${VAL_CONTENT_VIEW_ROUTE}${path}`;
setIsCompareView(isCompare);
- setSourcePath(isCompare ? ("" as SourcePath) : (path as SourcePath));
+ setIsErrorsView(isErrors);
+ setErrorFields(isErrors ? (params?.errorFields ?? []) : []);
+ setSourcePath(
+ isCompare || isErrors ? ("" as SourcePath) : (path as SourcePath),
+ );
if (!overlay) {
const shadowRoot =
document.getElementById("val-shadow-root")?.shadowRoot;
const scrollContainer = shadowRoot?.getElementById("val-content-area");
const prevScrollPos = scrollContainer?.scrollTop;
- const scrollId = params?.scrollToId;
- if (scrollId && shadowRoot) {
+ const scrollToPath = params?.scrollToPath;
+ if (scrollToPath && shadowRoot) {
let retriesLeft = 10;
const execScroll = () => {
- const element = shadowRoot.getElementById(scrollId);
+ const element = findStudioPathTarget(shadowRoot, scrollToPath);
if (element) {
doScroll(shadowRoot, element);
} else if (retriesLeft > 0) {
@@ -194,7 +246,10 @@ export function ValRouter({
}
} else {
window.location.href =
- navigateTo + (params?.scrollToId ? `#${params.scrollToId}` : "");
+ navigateTo +
+ (params?.scrollToPath
+ ? `#${encodeURIComponent(params.scrollToPath)}`
+ : "");
}
},
[overlay],
@@ -207,6 +262,8 @@ export function ValRouter({
navigate,
ready,
isCompareView,
+ isErrorsView,
+ errorFields,
}}
>
{children}
@@ -215,13 +272,21 @@ export function ValRouter({
}
export function useNavigation() {
- const { navigate, currentSourcePath, ready, isCompareView } =
- useContext(ValRouterContext);
+ const {
+ navigate,
+ currentSourcePath,
+ ready,
+ isCompareView,
+ isErrorsView,
+ errorFields,
+ } = useContext(ValRouterContext);
return {
navigate,
currentSourcePath,
ready,
isCompareView,
+ isErrorsView,
+ errorFields,
};
}
diff --git a/packages/ui/spa/components/ValidationErrors.stories.tsx b/packages/ui/spa/components/ValidationErrors.stories.tsx
new file mode 100644
index 000000000..13f99d7df
--- /dev/null
+++ b/packages/ui/spa/components/ValidationErrors.stories.tsx
@@ -0,0 +1,251 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import {
+ initVal,
+ Internal,
+ Json,
+ ModuleFilePath,
+ ReifiedRender,
+ SerializedSchema,
+ SourcePath,
+ ValidationError,
+} from "@valbuild/core";
+import { JSONValue } from "@valbuild/core/patch";
+import { ValClient } from "@valbuild/shared/internal";
+import { useMemo, useState } from "react";
+import { ValidationErrors } from "./ValidationErrors";
+import { ValSyncEngine } from "../ValSyncEngine";
+import { ValThemeProvider, Themes } from "./ValThemeProvider";
+import { ValErrorProvider } from "./ValErrorProvider";
+import { ValPortalProvider } from "./ValPortalProvider";
+import { ValFieldProvider } from "./ValFieldProvider";
+import { ValRouter } from "./ValRouter";
+import { ValRemoteProvider } from "./ValRemoteProvider";
+import { TooltipProvider } from "./designSystem/tooltip";
+
+function createMockClient(): ValClient {
+ return ((path: string, method: string) => {
+ if (path === "/patches" && method === "PUT") {
+ return Promise.resolve({ status: 200, json: { newPatchIds: [] } });
+ }
+ return Promise.resolve({
+ status: 200,
+ json: {
+ schemas: {},
+ sources: {},
+ config: { project: "storybook-test" },
+ },
+ });
+ }) as unknown as ValClient;
+}
+
+type MockData = {
+ schemas: Record;
+ sources: Record;
+ renders: Record;
+};
+
+function createMockData(
+ modules: ReturnType["c"]["define"]>[],
+): MockData {
+ const schemas: Record = {};
+ const sources: Record = {};
+ const renders: Record = {};
+ for (const module of modules) {
+ const moduleFilePath = Internal.getValPath(module);
+ const schema = Internal.getSchema(module);
+ const source = Internal.getSource(module);
+ if (moduleFilePath && schema && source !== undefined) {
+ const path = moduleFilePath as unknown as ModuleFilePath;
+ schemas[path] = schema["executeSerialize"]();
+ sources[path] = source;
+ renders[path] = schema["executeRender"](path, source);
+ }
+ }
+ return {
+ schemas: schemas as Record,
+ sources: sources as Record,
+ renders: renders as Record,
+ };
+}
+
+function makeEngine(client: ValClient, mockData: MockData): ValSyncEngine {
+ const engine = new ValSyncEngine(client, undefined);
+ engine.setSchemas(mockData.schemas);
+ engine.setSources(
+ mockData.sources as Record,
+ );
+ engine.setRenders(mockData.renders);
+ engine.setBaseSha("storybook-mock-sha");
+ engine.setInitializedAt(Date.now());
+ return engine;
+}
+
+function StoryProviders({
+ children,
+ syncEngine,
+}: {
+ children: React.ReactNode;
+ syncEngine: ValSyncEngine;
+}) {
+ const [theme, setTheme] = useState("dark");
+ const getDirectFileUploadSettings = useMemo(
+ () => async () => ({
+ status: "success" as const,
+ data: {
+ nonce: null,
+ baseUrl: "https://mock-upload.example.com",
+ contentBaseUrl: null,
+ contentAuthNonce: null,
+ },
+ }),
+ [],
+ );
+ return (
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+ );
+}
+
+const { s, c } = initVal();
+
+const PAGES_PATH = "/app/pages.val.ts" as ModuleFilePath;
+const pagesModule = c.define(
+ "/app/pages.val.ts",
+ s.record(
+ s.object({
+ title: s.string(),
+ body: s.string(),
+ }),
+ ),
+ {
+ "/home": { title: "Welcome Home", body: "Welcome to our site." },
+ "/about": { title: "About Us", body: "Learn more about our team." },
+ },
+);
+
+const SETTINGS_PATH = "/app/settings.val.ts" as ModuleFilePath;
+const settingsModule = c.define(
+ "/app/settings.val.ts",
+ s.object({
+ siteName: s.string(),
+ description: s.string(),
+ }),
+ { siteName: "My Site", description: "A great site." },
+);
+
+const mockData = createMockData([pagesModule, settingsModule]);
+
+function sourcePathOf(
+ moduleFilePath: ModuleFilePath,
+ segments: string[],
+): SourcePath {
+ return Internal.joinModuleFilePathAndModulePath(
+ moduleFilePath,
+ Internal.patchPathToModulePath(segments),
+ );
+}
+
+const homeTitlePath = sourcePathOf(PAGES_PATH, ["/home", "title"]);
+const homeBodyPath = sourcePathOf(PAGES_PATH, ["/home", "body"]);
+const aboutTitlePath = sourcePathOf(PAGES_PATH, ["/about", "title"]);
+const settingsSiteNamePath = sourcePathOf(SETTINGS_PATH, ["siteName"]);
+
+const sampleErrors: Record = {
+ [homeTitlePath]: [{ message: "Title is required." }],
+ [homeBodyPath]: [
+ { message: "Body must be at least 20 characters." },
+ { message: "Body should not contain placeholder text." },
+ ],
+ [aboutTitlePath]: [{ message: "Title must be at most 60 characters." }],
+ [settingsSiteNamePath]: [{ message: "Site name is required." }],
+};
+
+function StorySetup({
+ errorFields,
+ allErrors,
+}: {
+ errorFields: SourcePath[];
+ allErrors: Record;
+}) {
+ const client = useMemo(() => createMockClient(), []);
+ const engine = useMemo(() => makeEngine(client, mockData), [client]);
+ return (
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/ValidationErrors",
+ component: StorySetup,
+ parameters: { layout: "fullscreen" },
+ tags: ["autodocs"],
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Empty: Story = {
+ args: { errorFields: [], allErrors: {} },
+};
+
+export const SingleField: Story = {
+ args: {
+ errorFields: [homeTitlePath],
+ allErrors: sampleErrors,
+ },
+};
+
+export const MultipleFieldsAcrossModules: Story = {
+ args: {
+ errorFields: [
+ settingsSiteNamePath,
+ homeBodyPath,
+ aboutTitlePath,
+ homeTitlePath,
+ ],
+ allErrors: sampleErrors,
+ },
+};
+
+export const SelectedButNoErrorInStore: Story = {
+ args: { errorFields: [homeTitlePath], allErrors: {} },
+};
+
+export const AllFixed: Story = {
+ args: {
+ errorFields: [homeTitlePath, homeBodyPath, aboutTitlePath],
+ allErrors: {},
+ },
+};
diff --git a/packages/ui/spa/components/ValidationErrors.tsx b/packages/ui/spa/components/ValidationErrors.tsx
new file mode 100644
index 000000000..3a5b2d71a
--- /dev/null
+++ b/packages/ui/spa/components/ValidationErrors.tsx
@@ -0,0 +1,363 @@
+import {
+ Internal,
+ ModuleFilePath,
+ ModulePath,
+ SourcePath,
+ ValidationError,
+} from "@valbuild/core";
+import { Fragment, useMemo } from "react";
+import classNames from "classnames";
+import {
+ CheckCircle2,
+ FileCode2,
+ Globe,
+ Loader2,
+ TriangleAlert,
+} from "lucide-react";
+import { AnyField } from "./AnyField";
+import { FieldErrorList } from "./FieldErrorList";
+import { getNavPathFromAll } from "./getNavPath";
+import { useAllValidationErrors } from "./ValErrorProvider";
+import { useAllSources, useSchemaAtPath, useSchemas } from "./ValFieldProvider";
+import { useNavigation } from "./ValRouter";
+import { prettifyFilename } from "../utils/prettifyFilename";
+import { prettifyModulePath } from "../utils/prettifyText";
+import { urlOf } from "@valbuild/shared/internal";
+
+/**
+ * The list of rows shown on `/val/errors` is driven entirely by the
+ * `error-field` query params at page load — it never reacts to errors
+ * appearing or disappearing while the user is editing here, so rows are
+ * stable and the layout doesn't shift. The right-tools "Validation errors"
+ * button is responsible for re-snapshotting the URL when the user wants a
+ * fresh view.
+ *
+ * The page is grouped by module file, with a count pill per module, so a
+ * user fixing errors across several files always has an at-a-glance map of
+ * what's left. Errors use the warning palette rather than error-red so the
+ * page reads as "needs attention before publish", not "something is broken".
+ */
+export function ValidationErrors({
+ errorFields,
+ allErrors,
+}: {
+ errorFields: SourcePath[];
+ allErrors: Record | null;
+}) {
+ const grouped = useMemo(() => groupByModule(errorFields), [errorFields]);
+ const totalFields = errorFields.length;
+ const moduleCount = grouped.length;
+ const allFixed =
+ totalFields > 0 &&
+ errorFields.every(
+ (path) => !allErrors?.[path] || allErrors[path]!.length === 0,
+ );
+ const remainingFieldCount = errorFields.filter(
+ (path) => allErrors?.[path] && allErrors[path]!.length! > 0,
+ ).length;
+
+ if (errorFields.length === 0) {
+ return (
+
+ No errors selected.
+
+ );
+ }
+
+ return (
+
+
+
+
+ Validation errors
+
+ {allFixed ? (
+ All fixed
+ ) : (
+
+ {totalFields} {totalFields === 1 ? "field" : "fields"}
+ {moduleCount > 1 ? (
+ <>
+ {" across "}
+ {moduleCount} modules
+ >
+ ) : null}
+
+ )}
+
+
+ {allFixed &&
}
+
+ {!allFixed && remainingFieldCount < totalFields && totalFields > 1 && (
+
+ {remainingFieldCount} of {totalFields} still need attention.
+
+ )}
+
+
+ {grouped.map(({ moduleFilePath, paths }) => (
+
+ ))}
+
+
+ );
+}
+
+export function ValidationErrorsView() {
+ const { errorFields } = useNavigation();
+ const allErrors = useAllValidationErrors();
+ return ;
+}
+
+function ModuleGroup({
+ moduleFilePath,
+ paths,
+ allErrors,
+}: {
+ moduleFilePath: ModuleFilePath;
+ paths: SourcePath[];
+ allErrors: Record | null;
+}) {
+ const moduleParts = useMemo(
+ () => Internal.splitModuleFilePath(moduleFilePath),
+ [moduleFilePath],
+ );
+ const unresolvedCount = paths.filter(
+ (path) => allErrors?.[path] && allErrors[path]!.length > 0,
+ ).length;
+ return (
+
+
+
+
+ {moduleParts.map((part, i) => (
+
+ {i > 0 && / }
+
+ {prettifyFilename(part)}
+
+
+ ))}
+
+
+ {unresolvedCount === 0 ? (
+
+
+ Fixed
+
+ ) : (
+
+ {unresolvedCount} {unresolvedCount === 1 ? "issue" : "issues"}
+
+ )}
+
+
+
+ {paths.map((path, i) => (
+
+ ))}
+
+
+ );
+}
+
+function ValidationErrorRow({
+ sourcePath,
+ errors,
+ isLast,
+}: {
+ sourcePath: SourcePath;
+ errors: ValidationError[] | undefined;
+ isLast: boolean;
+}) {
+ const schemaAtPath = useSchemaAtPath(sourcePath);
+ const hasError = !!errors && errors.length > 0;
+ return (
+
+
+ {schemaAtPath.status === "success" ? (
+
+ ) : (
+
+
+ Loading field…
+
+ )}
+ {hasError ? (
+
+ ) : (
+
+
+ Fixed.
+
+ )}
+
+ );
+}
+
+function FieldPathLabel({ sourcePath }: { sourcePath: SourcePath }) {
+ const { navigate } = useNavigation();
+ const schemas = useSchemas();
+ const allSources = useAllSources();
+ const [moduleFilePath, modulePath] =
+ Internal.splitModuleFilePathAndModulePath(sourcePath);
+ const segments = modulePath
+ ? Internal.splitModulePath(modulePath as ModulePath)
+ : [];
+ const schemasData = schemas.status === "success" ? schemas.data : undefined;
+ const moduleSchema = schemasData?.[moduleFilePath];
+ const isRouterModule =
+ moduleSchema?.type === "record" && Boolean(moduleSchema.router);
+ const isRouterPageKey = isRouterModule && segments.length === 1;
+
+ const codeCls =
+ "font-mono text-sm px-2 py-0.5 rounded bg-bg-secondary text-fg-primary truncate cursor-pointer hover:bg-bg-tertiary transition-colors min-w-0 block";
+
+ const handleNavigate = () => {
+ const navPath = getNavPathFromAll(sourcePath, allSources, schemasData);
+ const target = navPath ?? sourcePath;
+ navigate(target, {
+ scrollToPath: target !== sourcePath ? sourcePath : undefined,
+ });
+ };
+
+ if (!modulePath) {
+ return (
+
+ {prettifyFilename(
+ Internal.splitModuleFilePath(moduleFilePath).pop() ?? "",
+ )}
+
+ );
+ }
+
+ if (isRouterPageKey) {
+ const segment = segments[0];
+ const previewHref = urlOf("/api/val/enable", {
+ redirect_to:
+ (typeof window !== "undefined" ? window.location.origin : "") + segment,
+ });
+ return (
+
+
+ {segment}
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {prettifyModulePath(modulePath)}
+
+ );
+}
+
+function CountPill({
+ children,
+ tone,
+}: {
+ children: React.ReactNode;
+ tone: "warning" | "success";
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+function AllFixedBanner({ total }: { total: number }) {
+ return (
+
+
+
+ All {total} {" "}
+ {total === 1 ? "error is" : "errors are"} fixed. Ready to publish.
+
+
+ );
+}
+
+function groupByModule(
+ errorFields: SourcePath[],
+): { moduleFilePath: ModuleFilePath; paths: SourcePath[] }[] {
+ const map = new Map();
+ for (const path of errorFields) {
+ const [moduleFilePath] = Internal.splitModuleFilePathAndModulePath(path);
+ const bucket = map.get(moduleFilePath);
+ if (bucket) {
+ bucket.push(path);
+ } else {
+ map.set(moduleFilePath, [path]);
+ }
+ }
+ const groups = Array.from(map.entries()).map(([moduleFilePath, paths]) => ({
+ moduleFilePath,
+ paths: paths.slice().sort(compareWithinModule),
+ }));
+ groups.sort((a, b) => a.moduleFilePath.localeCompare(b.moduleFilePath));
+ return groups;
+}
+
+function compareWithinModule(a: SourcePath, b: SourcePath): number {
+ const [, aPath] = Internal.splitModuleFilePathAndModulePath(a);
+ const [, bPath] = Internal.splitModuleFilePathAndModulePath(b);
+ const aSegs = aPath ? Internal.splitModulePath(aPath as ModulePath) : [];
+ const bSegs = bPath ? Internal.splitModulePath(bPath as ModulePath) : [];
+ const min = Math.min(aSegs.length, bSegs.length);
+ for (let i = 0; i < min; i++) {
+ if (aSegs[i] !== bSegs[i]) return aSegs[i].localeCompare(bSegs[i]);
+ }
+ return aSegs.length - bSegs.length;
+}
diff --git a/packages/ui/spa/components/designSystem/button.tsx b/packages/ui/spa/components/designSystem/button.tsx
index 824579a27..e023de531 100644
--- a/packages/ui/spa/components/designSystem/button.tsx
+++ b/packages/ui/spa/components/designSystem/button.tsx
@@ -29,6 +29,11 @@ const buttonVariants = cva(
"border border-bg-error-primary",
"bg-bg-error-primary text-fg-error-primary hover:bg-bg-error-primary-hover disabled:text-fg-error-primary aria-disabled:text-fg-error-primary",
),
+ warning: cn(
+ "cursor-pointer",
+ "border border-bg-warning-secondary",
+ "bg-bg-warning-secondary text-fg-warning-secondary hover:bg-bg-warning-secondary-hover disabled:text-fg-warning-secondary aria-disabled:text-fg-warning-secondary",
+ ),
outline: cn(
"cursor-pointer",
"border border-transparent",
diff --git a/packages/ui/spa/components/fields/ArrayFields.tsx b/packages/ui/spa/components/fields/ArrayFields.tsx
index b1cd0acba..b550b6846 100644
--- a/packages/ui/spa/components/fields/ArrayFields.tsx
+++ b/packages/ui/spa/components/fields/ArrayFields.tsx
@@ -28,11 +28,13 @@ export function ArrayFields({
readonly,
compact,
inline,
+ errorDisplay = "default",
}: {
path: SourcePath;
readonly?: boolean;
compact?: boolean;
inline?: boolean;
+ errorDisplay?: "default" | "compact" | "none";
}) {
const type = "array";
const creatorId = useFieldCreatorId();
@@ -123,6 +125,7 @@ export function ArrayFields({
type={schema.item.type}
readonly={readonly}
compact={compact}
+ errorDisplay={errorDisplay}
>
diff --git a/packages/ui/spa/components/fields/ObjectFields.tsx b/packages/ui/spa/components/fields/ObjectFields.tsx
index 5cd67a217..16d185342 100644
--- a/packages/ui/spa/components/fields/ObjectFields.tsx
+++ b/packages/ui/spa/components/fields/ObjectFields.tsx
@@ -17,11 +17,13 @@ export function ObjectFields({
readonly,
compact,
inline,
+ errorDisplay = "default",
}: {
path: SourcePath;
readonly?: boolean;
compact?: boolean;
inline?: boolean;
+ errorDisplay?: "default" | "compact" | "none";
}) {
const type = "object";
const schemaAtPath = useSchemaAtPath(path);
@@ -76,6 +78,7 @@ export function ObjectFields({
type={itemSchema.type}
readonly={readonly}
compact={compact}
+ errorDisplay={errorDisplay}
>
);
diff --git a/packages/ui/spa/components/fields/RecordFields.tsx b/packages/ui/spa/components/fields/RecordFields.tsx
index d2ff40941..ca8a9a40e 100644
--- a/packages/ui/spa/components/fields/RecordFields.tsx
+++ b/packages/ui/spa/components/fields/RecordFields.tsx
@@ -31,11 +31,13 @@ export function RecordFields({
readonly,
compact,
inline,
+ errorDisplay = "default",
}: {
path: SourcePath;
readonly?: boolean;
compact?: boolean;
inline?: boolean;
+ errorDisplay?: "default" | "compact" | "none";
}) {
const type = "record";
const validationErrors = useAllValidationErrors() || {};
@@ -101,6 +103,7 @@ export function RecordFields({
type={schema.item.type}
readonly={readonly}
compact={compact}
+ errorDisplay={errorDisplay}
>
))}
diff --git a/packages/ui/spa/components/fields/UnionField.tsx b/packages/ui/spa/components/fields/UnionField.tsx
index f3389f554..c3dceb41a 100644
--- a/packages/ui/spa/components/fields/UnionField.tsx
+++ b/packages/ui/spa/components/fields/UnionField.tsx
@@ -48,11 +48,13 @@ export function UnionField({
readonly,
compact,
inline,
+ errorDisplay = "default",
}: {
path: SourcePath;
readonly?: boolean;
compact?: boolean;
inline?: boolean;
+ errorDisplay?: "default" | "compact" | "none";
}) {
const type = "union";
const schemaAtPath = useSchemaAtPath(path);
@@ -159,6 +161,7 @@ export function UnionField({
readonly={readonly}
compact={compact}
inline={inline}
+ errorDisplay={errorDisplay}
/>
);
@@ -171,12 +174,14 @@ function ObjectUnionField({
readonly,
compact,
inline,
+ errorDisplay = "default",
}: {
path: SourcePath;
schema: SerializedObjectUnionSchema;
readonly?: boolean;
compact?: boolean;
inline?: boolean;
+ errorDisplay?: "default" | "compact" | "none";
}) {
const fullSourceAtPath = useSourceAtPath(path);
const { addPatch, patchPath } = useAddPatch(path);
@@ -293,6 +298,7 @@ function ObjectUnionField({
type={selectedSchema?.items?.[key]?.type}
readonly={readonly}
compact={compact}
+ errorDisplay={errorDisplay}
>
);
diff --git a/packages/ui/spa/index.css b/packages/ui/spa/index.css
index b238d8b46..bb8fcddc3 100644
--- a/packages/ui/spa/index.css
+++ b/packages/ui/spa/index.css
@@ -114,7 +114,18 @@
--border-brand-secondary: var(--colors-brand-green-500);
/* Warning colors */
+ /* Warning primary: soft tinted surface for cards/banners */
--bg-warning-primary: var(--colors-warning-yellow-50);
+ --bg-warning-primary-hover: var(--colors-warning-yellow-100);
+ --fg-warning-primary: var(--colors-warning-yellow-800);
+ --fg-warning-primary-alt: var(--colors-warning-yellow-700);
+ --border-warning-primary: var(--colors-warning-yellow-300);
+ /* Warning secondary: filled accent (rail, icon, pill) */
+ --bg-warning-secondary: var(--colors-warning-yellow-500);
+ --bg-warning-secondary-hover: var(--colors-warning-yellow-600);
+ --fg-warning-secondary: var(--colors-warning-yellow-50);
+ --fg-warning-secondary-alt: var(--colors-warning-yellow-100);
+ --border-warning-secondary: var(--colors-warning-yellow-600);
/* Error colors */
/* Error primary */
@@ -174,7 +185,18 @@
--border-brand-secondary: var(--colors-brand-green-400);
/* Warning colors */
- --bg-warning-primary: var(--colors-warning-yellow-500);
+ /* Warning primary: soft tinted surface for cards/banners */
+ --bg-warning-primary: var(--colors-warning-yellow-950);
+ --bg-warning-primary-hover: var(--colors-warning-yellow-900);
+ --fg-warning-primary: var(--colors-warning-yellow-100);
+ --fg-warning-primary-alt: var(--colors-warning-yellow-200);
+ --border-warning-primary: var(--colors-warning-yellow-700);
+ /* Warning secondary: filled accent (rail, icon, pill) */
+ --bg-warning-secondary: var(--colors-warning-yellow-400);
+ --bg-warning-secondary-hover: var(--colors-warning-yellow-300);
+ --fg-warning-secondary: var(--colors-warning-yellow-950);
+ --fg-warning-secondary-alt: var(--colors-warning-yellow-900);
+ --border-warning-secondary: var(--colors-warning-yellow-500);
/* Error colors */
/* Error primary */
diff --git a/packages/ui/spa/utils/prettifyText.ts b/packages/ui/spa/utils/prettifyText.ts
index 24d1c998c..032a32eaf 100644
--- a/packages/ui/spa/utils/prettifyText.ts
+++ b/packages/ui/spa/utils/prettifyText.ts
@@ -1,4 +1,18 @@
+import { Internal } from "@valbuild/core";
+
export function fromCamelToTitleCase(text: string): string {
const result = text.replace(/([A-Z])/g, " $1").toLowerCase();
return result.charAt(0).toUpperCase() + result.slice(1);
}
+
+export function prettifyModulePath(modulePath: string): string {
+ // Show "Home / Title" instead of '"home"."title"'. Router-page slugs like
+ // "/blogs/blog-12" start with "/" so the casing helper leaves them intact.
+ if (!modulePath) return modulePath;
+ // splitModulePath accepts the same string the engine uses internally; it's
+ // a branded type at the boundary but the existing helpers are tolerant.
+ const segments = Internal.splitModulePath(
+ modulePath as Parameters[0],
+ );
+ return segments.map(fromCamelToTitleCase).join(" / ");
+}
diff --git a/packages/ui/tailwind.config.js b/packages/ui/tailwind.config.js
index 9ac98ef08..9619aa3bc 100644
--- a/packages/ui/tailwind.config.js
+++ b/packages/ui/tailwind.config.js
@@ -50,6 +50,15 @@ module.exports = {
"fg-brand-secondary-alt": "var(--fg-brand-secondary-alt)",
"border-brand-secondary": "var(--border-brand-secondary)",
"bg-warning-primary": "var(--bg-warning-primary)",
+ "bg-warning-primary-hover": "var(--bg-warning-primary-hover)",
+ "fg-warning-primary": "var(--fg-warning-primary)",
+ "fg-warning-primary-alt": "var(--fg-warning-primary-alt)",
+ "border-warning-primary": "var(--border-warning-primary)",
+ "bg-warning-secondary": "var(--bg-warning-secondary)",
+ "bg-warning-secondary-hover": "var(--bg-warning-secondary-hover)",
+ "fg-warning-secondary": "var(--fg-warning-secondary)",
+ "fg-warning-secondary-alt": "var(--fg-warning-secondary-alt)",
+ "border-warning-secondary": "var(--border-warning-secondary)",
"bg-error-primary": "var(--bg-error-primary)",
"bg-error-primary-hover": "var(--bg-error-primary-hover)",
"fg-error-primary": "var(--fg-error-primary)",