diff --git a/apps/compliance-portal/src/App.tsx b/apps/compliance-portal/src/App.tsx
index c8471e4e5..f7f4e21dc 100644
--- a/apps/compliance-portal/src/App.tsx
+++ b/apps/compliance-portal/src/App.tsx
@@ -18,15 +18,19 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
+import { ErrorBoundary } from "@probo/ui/src/v2/ErrorBoundary/ErrorBoundary";
import { RouterProvider } from "react-router";
+import { BootstrapError } from "#/components/errors/BootstrapError";
import { RelayProvider } from "#/lib/relay/RelayProvider";
import { router } from "#/routes";
export function App() {
return (
-
-
-
+ }>
+
+
+
+
);
}
diff --git a/apps/compliance-portal/src/_locales/en-US.json b/apps/compliance-portal/src/_locales/en-US.json
index aa9d0baea..482f7dbe7 100644
--- a/apps/compliance-portal/src/_locales/en-US.json
+++ b/apps/compliance-portal/src/_locales/en-US.json
@@ -36,6 +36,32 @@
"description": "The page you are looking for does not exist or has moved.",
"backHome": "Back to home"
},
+ "errors": {
+ "notFound": {
+ "title": "Page not found",
+ "description": "The page you're looking for doesn't exist or may have been moved."
+ },
+ "forbidden": {
+ "title": "Access denied",
+ "description": "You don't have permission to view this page."
+ },
+ "serverError": {
+ "title": "Something went wrong",
+ "description": "We hit an unexpected error. Please try again in a moment."
+ },
+ "generic": {
+ "title": "Something went wrong",
+ "description": "We hit an unexpected error. Please try again in a moment."
+ },
+ "actions": {
+ "backToTrustCenter": "Back to trust center",
+ "tryAgain": "Try again"
+ },
+ "inline": {
+ "message": "Unable to load content",
+ "retry": "Retry"
+ }
+ },
"footer": {
"poweredBy": "Powered by"
},
diff --git a/apps/compliance-portal/src/_locales/fr-FR.json b/apps/compliance-portal/src/_locales/fr-FR.json
index 8d043eeab..aa603edb3 100644
--- a/apps/compliance-portal/src/_locales/fr-FR.json
+++ b/apps/compliance-portal/src/_locales/fr-FR.json
@@ -36,6 +36,32 @@
"description": "La page que vous recherchez n'existe pas ou a été déplacée.",
"backHome": "Retour à l'accueil"
},
+ "errors": {
+ "notFound": {
+ "title": "Page introuvable",
+ "description": "La page que vous recherchez n'existe pas ou a été déplacée."
+ },
+ "forbidden": {
+ "title": "Accès refusé",
+ "description": "Vous n'avez pas l'autorisation de consulter cette page."
+ },
+ "serverError": {
+ "title": "Une erreur est survenue",
+ "description": "Une erreur inattendue s'est produite. Veuillez réessayer dans un instant."
+ },
+ "generic": {
+ "title": "Une erreur est survenue",
+ "description": "Une erreur inattendue s'est produite. Veuillez réessayer dans un instant."
+ },
+ "actions": {
+ "backToTrustCenter": "Retour au trust center",
+ "tryAgain": "Réessayer"
+ },
+ "inline": {
+ "message": "Impossible de charger le contenu",
+ "retry": "Réessayer"
+ }
+ },
"footer": {
"poweredBy": "Propulsé par"
},
diff --git a/apps/compliance-portal/src/components/ComplianceFrameworks/ComplianceFrameworksSection.tsx b/apps/compliance-portal/src/components/ComplianceFrameworks/ComplianceFrameworksSection.tsx
index ae0422432..385c58994 100644
--- a/apps/compliance-portal/src/components/ComplianceFrameworks/ComplianceFrameworksSection.tsx
+++ b/apps/compliance-portal/src/components/ComplianceFrameworks/ComplianceFrameworksSection.tsx
@@ -18,6 +18,8 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
+import { ErrorBoundary } from "@probo/ui/src/v2/ErrorBoundary/ErrorBoundary";
+import { InlineError } from "@probo/ui/src/v2/InlineError/InlineError";
import { useTranslation } from "react-i18next";
import { graphql, useFragment } from "react-relay";
@@ -26,8 +28,11 @@ import { HomeSection } from "#/components/HomeSection/HomeSection";
import type { ComplianceFrameworksSection_trustCenter$key } from "./__generated__/ComplianceFrameworksSection_trustCenter.graphql";
import { ComplianceFrameworkListItem } from "./ComplianceFrameworkListItem";
+// @throwOnFieldError makes a field error in this fragment throw at the read
+// below, where the section's ErrorBoundary contains it. See
+// contrib/claude/error-handling.md.
const complianceFrameworksSectionFragment = graphql`
- fragment ComplianceFrameworksSection_trustCenter on TrustCenter {
+ fragment ComplianceFrameworksSection_trustCenter on TrustCenter @throwOnFieldError {
complianceFrameworks(first: 8) {
edges {
node {
@@ -44,9 +49,30 @@ interface ComplianceFrameworksSectionProps {
}
// "Compliance" section: the grid of certification frameworks the trust center
-// covers.
+// covers. Wraps its data-reading content in a boundary so a load failure
+// degrades to an inline error instead of taking down the page.
export function ComplianceFrameworksSection({ trustCenterKey }: ComplianceFrameworksSectionProps) {
const { t } = useTranslation();
+
+ return (
+ (
+
+
+
+ )}
+ >
+
+
+ );
+}
+
+function ComplianceFrameworksSectionContent({ trustCenterKey }: ComplianceFrameworksSectionProps) {
+ const { t } = useTranslation();
const data = useFragment(complianceFrameworksSectionFragment, trustCenterKey);
const frameworks = data.complianceFrameworks.edges.map(edge => edge.node);
diff --git a/apps/compliance-portal/src/components/MailingListUpdateListItem/MailingListUpdateListItem.tsx b/apps/compliance-portal/src/components/MailingListUpdateListItem/MailingListUpdateListItem.tsx
index 30b89f337..29fbd6800 100644
--- a/apps/compliance-portal/src/components/MailingListUpdateListItem/MailingListUpdateListItem.tsx
+++ b/apps/compliance-portal/src/components/MailingListUpdateListItem/MailingListUpdateListItem.tsx
@@ -29,7 +29,7 @@ import { formatRelativeTime } from "#/lib/datetime/relativeTime";
import type { MailingListUpdateListItem_update$key } from "./__generated__/MailingListUpdateListItem_update.graphql";
const mailingListUpdateListItemFragment = graphql`
- fragment MailingListUpdateListItem_update on MailingListUpdate {
+ fragment MailingListUpdateListItem_update on MailingListUpdate @throwOnFieldError {
id
title
updatedAt
diff --git a/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSection.tsx b/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSection.tsx
index acff9cb54..334f0a504 100644
--- a/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSection.tsx
+++ b/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSection.tsx
@@ -19,6 +19,8 @@
// SOFTWARE.
import { Link } from "@probo/ui/src/v2/Button/Link";
+import { ErrorBoundary } from "@probo/ui/src/v2/ErrorBoundary/ErrorBoundary";
+import { InlineError } from "@probo/ui/src/v2/InlineError/InlineError";
import { useTranslation } from "react-i18next";
import { graphql, useFragment } from "react-relay";
@@ -28,8 +30,10 @@ import { dotPatternStyle } from "#/components/MediaTile/variants";
import type { RecentUpdatesSection_trustCenter$key } from "./__generated__/RecentUpdatesSection_trustCenter.graphql";
+// @throwOnFieldError surfaces a field error at the read below so the section
+// ErrorBoundary contains it. See contrib/claude/error-handling.md.
const recentUpdatesSectionFragment = graphql`
- fragment RecentUpdatesSection_trustCenter on TrustCenter {
+ fragment RecentUpdatesSection_trustCenter on TrustCenter @throwOnFieldError {
updates(first: 5) {
edges {
node {
@@ -46,9 +50,29 @@ interface RecentUpdatesSectionProps {
}
// "Recent updates" section: the latest mailing-list updates as a list, with a
-// link to the full updates page.
+// link to the full updates page. A load failure degrades to an inline error.
export function RecentUpdatesSection({ trustCenterKey }: RecentUpdatesSectionProps) {
const { t } = useTranslation();
+
+ return (
+ (
+
+
+
+ )}
+ >
+
+
+ );
+}
+
+function RecentUpdatesSectionContent({ trustCenterKey }: RecentUpdatesSectionProps) {
+ const { t } = useTranslation();
const data = useFragment(recentUpdatesSectionFragment, trustCenterKey);
const updates = data.updates.edges.map(edge => edge.node);
@@ -70,7 +94,22 @@ export function RecentUpdatesSection({ trustCenterKey }: RecentUpdatesSectionPro
{updates.map(update => (
-
+ // A single failing row degrades to a compact horizontal inline error.
+ (
+
+
+
+ )}
+ >
+
+
))}
diff --git a/apps/compliance-portal/src/components/errors/BootstrapError.tsx b/apps/compliance-portal/src/components/errors/BootstrapError.tsx
new file mode 100644
index 000000000..759d0527e
--- /dev/null
+++ b/apps/compliance-portal/src/components/errors/BootstrapError.tsx
@@ -0,0 +1,45 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+import { Anchor } from "@probo/ui/src/v2/Button/Anchor";
+import { Button } from "@probo/ui/src/v2/Button/Button";
+import { ErrorState } from "@probo/ui/src/v2/ErrorState/ErrorState";
+import { useTranslation } from "react-i18next";
+
+import { getPathPrefix } from "#/lib/http/pathPrefix";
+
+// Outermost fallback for failures that happen before (or in) the router itself.
+// It cannot use the router (no context yet), so navigation is a plain anchor and
+// recovery is a hard reload.
+export function BootstrapError() {
+ const { t } = useTranslation();
+
+ return (
+
+
+ {t("errors.actions.backToTrustCenter")}
+
+
+ >
+ )}
+ />
+ );
+}
diff --git a/apps/compliance-portal/src/components/errors/GlobalError.tsx b/apps/compliance-portal/src/components/errors/GlobalError.tsx
new file mode 100644
index 000000000..ecb266b91
--- /dev/null
+++ b/apps/compliance-portal/src/components/errors/GlobalError.tsx
@@ -0,0 +1,89 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+import { ForbiddenError, InternalServerError, UnAuthenticatedError } from "@probo/relay";
+import { Button } from "@probo/ui/src/v2/Button/Button";
+import { Link } from "@probo/ui/src/v2/Button/Link";
+import { ErrorState } from "@probo/ui/src/v2/ErrorState/ErrorState";
+import { useTranslation } from "react-i18next";
+
+import { NotFoundError } from "#/lib/relay/errors";
+
+interface ErrorContent {
+ code?: string;
+ titleKey: string;
+ descriptionKey: string;
+}
+
+// Map a caught error to the page-level copy. Recognizes the portal error
+// classes first, then falls back to the code embedded in generic Error messages
+// (thrown request-level by lib/relay/fetch.ts).
+function resolveContent(error: unknown): ErrorContent {
+ const message = error instanceof Error ? error.message : "";
+
+ if (error instanceof NotFoundError || message.includes("NOT_FOUND")) {
+ return { code: "404", titleKey: "errors.notFound.title", descriptionKey: "errors.notFound.description" };
+ }
+
+ if (
+ error instanceof ForbiddenError
+ || error instanceof UnAuthenticatedError
+ || message.includes("FORBIDDEN")
+ || message.includes("UNAUTHENTICATED")
+ ) {
+ return { code: "403", titleKey: "errors.forbidden.title", descriptionKey: "errors.forbidden.description" };
+ }
+
+ if (error instanceof InternalServerError || message.includes("INTERNAL_SERVER_ERROR")) {
+ return { code: "500", titleKey: "errors.serverError.title", descriptionKey: "errors.serverError.description" };
+ }
+
+ return { titleKey: "errors.generic.title", descriptionKey: "errors.generic.description" };
+}
+
+interface GlobalErrorProps {
+ error: unknown;
+ // When provided, a "Try again" secondary action is shown.
+ onRetry?: () => void;
+ // Full viewport (standalone) vs inside the app chrome (in-shell).
+ fullPage?: boolean;
+}
+
+// Page-level error fallback: renders the v2 ErrorState with portal copy and
+// actions. Used by the bootstrap boundary and the route boundaries.
+export function GlobalError({ error, onRetry, fullPage = false }: GlobalErrorProps) {
+ const { t } = useTranslation();
+ const { code, titleKey, descriptionKey } = resolveContent(error);
+
+ return (
+
+
+ {t("errors.actions.backToTrustCenter")}
+
+ {onRetry && (
+
+ )}
+ >
+ )}
+ />
+ );
+}
diff --git a/apps/compliance-portal/src/components/errors/PageErrorBoundary.tsx b/apps/compliance-portal/src/components/errors/PageErrorBoundary.tsx
new file mode 100644
index 000000000..8ebe3ba11
--- /dev/null
+++ b/apps/compliance-portal/src/components/errors/PageErrorBoundary.tsx
@@ -0,0 +1,30 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+import { useRouteError } from "react-router";
+
+import { GlobalError } from "./GlobalError";
+
+// Child-route boundary: a page failure is contained to the layout's Outlet, so
+// the error renders inside the app chrome (TopBar + footer survive).
+export function PageErrorBoundary() {
+ const error = useRouteError();
+
+ return (
+ window.location.reload()}
+ />
+ );
+}
diff --git a/apps/compliance-portal/src/components/errors/RootErrorBoundary.tsx b/apps/compliance-portal/src/components/errors/RootErrorBoundary.tsx
new file mode 100644
index 000000000..fb17adeb0
--- /dev/null
+++ b/apps/compliance-portal/src/components/errors/RootErrorBoundary.tsx
@@ -0,0 +1,32 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+import { useRouteError } from "react-router";
+
+import { GlobalError } from "./GlobalError";
+
+// Root route boundary: a failure in the layout (or anything above the page
+// boundaries) takes down the whole tree, so it renders a standalone full-page
+// error without the app chrome.
+export function RootErrorBoundary() {
+ const error = useRouteError();
+
+ return (
+ window.location.reload()}
+ />
+ );
+}
diff --git a/apps/compliance-portal/src/lib/relay/environment.ts b/apps/compliance-portal/src/lib/relay/environment.ts
index bd7934a67..f1bb7b17c 100644
--- a/apps/compliance-portal/src/lib/relay/environment.ts
+++ b/apps/compliance-portal/src/lib/relay/environment.ts
@@ -18,10 +18,10 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
-import { makeFetchQuery } from "@probo/relay";
import { Environment, Network, RecordSource, Store } from "relay-runtime";
import { buildEndpoint } from "#/lib/http/endpoint";
+import { makeFetchQuery } from "#/lib/relay/fetch";
const store = new Store(new RecordSource(), {
queryCacheExpirationTime: 1 * 60 * 1000,
diff --git a/apps/compliance-portal/src/lib/relay/errors.ts b/apps/compliance-portal/src/lib/relay/errors.ts
new file mode 100644
index 000000000..83ead5089
--- /dev/null
+++ b/apps/compliance-portal/src/lib/relay/errors.ts
@@ -0,0 +1,25 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+// Thrown when a fetched `node(id:)` resolves to a type other than the one the
+// view expects (e.g. `node.__typename !== "MailingListUpdate"`). Treated as a
+// not-found (404) by the error boundaries. Prefer this over a bare
+// `throw new Error(...)` so the boundary can render the dedicated 404 state.
+export class NotFoundError extends Error {
+ constructor(message?: string) {
+ super(message ?? "NOT_FOUND");
+ this.name = "NotFoundError";
+ Object.setPrototypeOf(this, NotFoundError.prototype);
+ }
+}
diff --git a/apps/compliance-portal/src/lib/relay/fetch.ts b/apps/compliance-portal/src/lib/relay/fetch.ts
new file mode 100644
index 000000000..302c45ef2
--- /dev/null
+++ b/apps/compliance-portal/src/lib/relay/fetch.ts
@@ -0,0 +1,123 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+import { ForbiddenError, InternalServerError, UnAuthenticatedError } from "@probo/relay";
+import { type GraphQLError } from "graphql";
+import { type FetchFunction, type GraphQLResponse } from "relay-runtime";
+
+// A GraphQL error is "request-level" when it has no `path` — it applies to the
+// whole operation (auth, malformed request, transport) rather than a single
+// field. Field-level errors carry a `path` and are left in the response so
+// Relay can attribute them to the reading field; combined with
+// `@throwOnFieldError` on a query/fragment, they surface at the nearest error
+// boundary around the component that reads them, instead of collapsing the whole
+// operation. See contrib/claude/error-handling.md.
+const isRequestLevel = (error: GraphQLError) =>
+ error.path === undefined || error.path === null || error.path.length === 0;
+
+// The portal fetch only throws for request-level failures. Everything else
+// (field-level errors) flows through to Relay untouched.
+export const makeFetchQuery = (endpoint: string): FetchFunction => {
+ return async (request, variables, _, uploadables) => {
+ const requestInit: RequestInit = {
+ method: "POST",
+ credentials: "include",
+ headers: {},
+ };
+
+ if (uploadables) {
+ const formData = new FormData();
+ formData.append(
+ "operations",
+ JSON.stringify({
+ operationName: request.name,
+ query: request.text,
+ variables: variables,
+ }),
+ );
+
+ const uploadableMap: {
+ [key: string]: string[];
+ } = {};
+ const uploadableKeys = Object.keys(uploadables);
+
+ uploadableKeys.forEach((key) => {
+ uploadableMap[key] = [`variables.${key}`];
+ });
+
+ formData.append("map", JSON.stringify(uploadableMap));
+
+ uploadableKeys.forEach((key) => {
+ formData.append(key, uploadables[key]);
+ });
+
+ requestInit.body = formData;
+ } else {
+ requestInit.headers = {
+ "Accept": "application/graphql-response+json; charset=utf-8, application/json; charset=utf-8",
+ "Content-Type": "application/json",
+ };
+
+ requestInit.body = JSON.stringify({
+ operationName: request.name,
+ query: request.text,
+ variables,
+ });
+ }
+
+ const response = await fetch(endpoint, requestInit);
+
+ if (response.status === 500) {
+ throw new InternalServerError();
+ }
+
+ const json = (await response.json()) as GraphQLResponse & {
+ errors?: GraphQLError[];
+ };
+
+ if (json.errors) {
+ // An unauthenticated session is always a global concern: the backend
+ // attaches the resolver path even to auth errors, so we must scan every
+ // error (not just request-level ones) and redirect regardless of where it
+ // surfaced.
+ const unauthenticated = json.errors.find(
+ error => error.extensions?.code === "UNAUTHENTICATED",
+ );
+ if (unauthenticated) {
+ throw new UnAuthenticatedError(unauthenticated.message);
+ }
+
+ // Everything else is only thrown here when it is request-level (no path) —
+ // a whole-operation failure. Field-level errors (including a FORBIDDEN on
+ // a single field/section) are left in the response so Relay surfaces them
+ // at the reading component via @throwOnFieldError, containing the failure
+ // to that component's boundary instead of the whole page.
+ const requestErrors = json.errors.filter(isRequestLevel);
+
+ const forbidden = requestErrors.find(
+ error => error.extensions?.code === "FORBIDDEN",
+ );
+ if (forbidden) {
+ throw new ForbiddenError(forbidden.message);
+ }
+
+ const requestError = requestErrors[0];
+ if (requestError) {
+ throw new Error(requestError.message);
+ }
+ }
+
+ return json;
+ };
+};
diff --git a/apps/compliance-portal/src/pages/HomePage.tsx b/apps/compliance-portal/src/pages/HomePage.tsx
index 847ce1543..3d2a70f3f 100644
--- a/apps/compliance-portal/src/pages/HomePage.tsx
+++ b/apps/compliance-portal/src/pages/HomePage.tsx
@@ -32,7 +32,7 @@ import { TrustedBySection } from "#/components/TrustedBy/TrustedBySection";
import type { HomePageQuery } from "./__generated__/HomePageQuery.graphql";
export const homePageQuery = graphql`
- query HomePageQuery {
+ query HomePageQuery @throwOnFieldError {
currentTrustCenter @required(action: THROW) {
organization {
name
diff --git a/apps/compliance-portal/src/pages/updates/UpdateDetailPage.tsx b/apps/compliance-portal/src/pages/updates/UpdateDetailPage.tsx
index 5714fab73..08fb5a2dd 100644
--- a/apps/compliance-portal/src/pages/updates/UpdateDetailPage.tsx
+++ b/apps/compliance-portal/src/pages/updates/UpdateDetailPage.tsx
@@ -28,13 +28,14 @@ import { graphql, usePreloadedQuery } from "react-relay";
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
import { formatDate } from "#/lib/datetime/formatDate";
+import { NotFoundError } from "#/lib/relay/errors";
import type { UpdateDetailPageQuery } from "./__generated__/UpdateDetailPageQuery.graphql";
import { UpdatesSubscribeButton } from "./_components/UpdatesSubscribeButton";
import { updateArticle } from "./_components/variants";
export const updateDetailPageQuery = graphql`
- query UpdateDetailPageQuery($updateId: ID!) {
+ query UpdateDetailPageQuery($updateId: ID!) @throwOnFieldError {
node(id: $updateId) {
__typename
... on MailingListUpdate {
@@ -55,7 +56,7 @@ export function UpdateDetailPage({ queryRef }: UpdateDetailPageProps) {
const data = usePreloadedQuery(updateDetailPageQuery, queryRef);
if (data.node?.__typename !== "MailingListUpdate") {
- throw new Error("Update not found");
+ throw new NotFoundError("Update not found");
}
const update = data.node;
diff --git a/apps/compliance-portal/src/routes.tsx b/apps/compliance-portal/src/routes.tsx
index 14944792f..8ad4dd3f3 100644
--- a/apps/compliance-portal/src/routes.tsx
+++ b/apps/compliance-portal/src/routes.tsx
@@ -22,6 +22,8 @@ import { lazy } from "@probo/react-lazy";
import { type AppRoute, routeFromAppRoute } from "@probo/routes";
import { createBrowserRouter } from "react-router";
+import { PageErrorBoundary } from "#/components/errors/PageErrorBoundary";
+import { RootErrorBoundary } from "#/components/errors/RootErrorBoundary";
import { getPathPrefix } from "#/lib/http/pathPrefix";
import { HomePageSkeleton } from "#/pages/HomePageSkeleton";
import { MainLayoutSkeleton } from "#/pages/MainLayoutSkeleton";
@@ -33,25 +35,34 @@ const routes = [
path: "/",
Fallback: MainLayoutSkeleton,
Component: lazy(() => import("#/pages/MainLayoutLoader")),
+ // A layout failure takes down the shell, so it shows a standalone full page.
+ ErrorBoundary: RootErrorBoundary,
children: [
{
- index: true,
- Fallback: HomePageSkeleton,
- Component: lazy(() => import("#/pages/HomePageLoader")),
- },
- {
- path: "documents",
- Component: lazy(() => import("#/pages/DocumentsPage")),
- },
- ...subprocessorRoutes,
- ...updateRoutes,
- {
- path: "requests",
- Component: lazy(() => import("#/pages/RequestsPage")),
- },
- {
- path: "*",
- Component: lazy(() => import("#/pages/NotFoundPage")),
+ // Pathless layout route: page failures bubble here and render inside the
+ // MainLayout Outlet, keeping the TopBar and footer chrome.
+ ErrorBoundary: PageErrorBoundary,
+ children: [
+ {
+ index: true,
+ Fallback: HomePageSkeleton,
+ Component: lazy(() => import("#/pages/HomePageLoader")),
+ },
+ {
+ path: "documents",
+ Component: lazy(() => import("#/pages/DocumentsPage")),
+ },
+ ...subprocessorRoutes,
+ ...updateRoutes,
+ {
+ path: "requests",
+ Component: lazy(() => import("#/pages/RequestsPage")),
+ },
+ {
+ path: "*",
+ Component: lazy(() => import("#/pages/NotFoundPage")),
+ },
+ ],
},
],
},
diff --git a/contrib/claude/error-handling.md b/contrib/claude/error-handling.md
index 9eabe8abe..a117ff93e 100644
--- a/contrib/claude/error-handling.md
+++ b/contrib/claude/error-handling.md
@@ -156,6 +156,93 @@ function PublishButton() {
For Relay mutations, prefer the built-in `onCompleted` / `onError` callbacks (see [`relay.md`](relay.md)) over a manual `try`/`catch`; use `try`/`catch` for non-Relay async work (fetch, parsing, third-party SDKs).
+## Relay field errors and fragment-level boundaries
+
+A GraphQL response can be **partial**: `data` is present but one field carries an
+error (with a `path`). To contain such a failure to the component that reads the
+bad field — instead of collapsing the whole page — two pieces cooperate:
+
+1. **The fetch layer only throws request-level errors.** A request-level error
+ has **no `path`** (auth, malformed request, transport) and applies to the
+ whole operation, so it throws and propagates to the nearest boundary.
+ Field-level errors (those with a `path`) are **left in the response** so Relay
+ can attribute them to the reading field.
+
+ The compliance-portal wires this in its own
+ [`apps/compliance-portal/src/lib/relay/fetch.ts`](../../apps/compliance-portal/src/lib/relay/fetch.ts)
+ (it does **not** use `@probo/relay`'s `makeFetchQuery`, which throws for the
+ whole operation on any known code).
+
+2. **`@throwOnFieldError` on the query/fragment that reads the field.** With the
+ directive set, a field error throws **at the read site** (`usePreloadedQuery`
+ for a query, `useFragment` for a fragment). Put it on the **fragment** to
+ isolate a section/row, and on the **query** to route page-level field errors
+ to the route boundary.
+
+Because `useFragment` throws in the component body (not in a child), the boundary
+must be an **ancestor**. Split the component into a thin wrapper (holds the
+`ErrorBoundary`) and a `*Content` child (reads the fragment):
+
+```tsx
+export function RecentUpdatesSection({ trustCenterKey }: Props) {
+ const { t } = useTranslation();
+ return (
+ (
+
+ )}
+ >
+
+
+ );
+}
+
+function RecentUpdatesSectionContent({ trustCenterKey }: Props) {
+ const data = useFragment(fragment, trustCenterKey); // throws here on a field error
+ // ...
+}
+
+const fragment = graphql`
+ fragment RecentUpdatesSection_trustCenter on TrustCenter @throwOnFieldError { ... }
+`;
+```
+
+The portal ships three fallback tiers, all backed by the same `ErrorBoundary`:
+
+| Tier | Placement | Fallback |
+|------|-----------|----------|
+| Global (bootstrap) | around `RouterProvider` in `App.tsx`, and the root route | `ErrorState` full page (standalone) |
+| Page | pathless child route inside the layout | `ErrorState` inside the shell (TopBar/footer survive) |
+| Section / row | around a fragment-reading subtree | `InlineError` (vertical for sections, horizontal for rows) with a retry |
+
+`ErrorState` and `InlineError` are presentational v2 kit components (see
+[`ui.md`](ui.md)); the app maps the caught error to copy/actions and passes them
+in.
+
+## Custom errors for node-type mismatches
+
+When a page fetches `node(id:)` and the resolved `__typename` is not the type the
+view expects, throw a dedicated error, not a bare `Error`, so the boundary can
+render the correct state (404):
+
+```tsx
+// Good — a typed error the boundary maps to the not-found page
+import { NotFoundError } from "#/lib/relay/errors";
+
+if (data.node?.__typename !== "MailingListUpdate") {
+ throw new NotFoundError("Update not found");
+}
+```
+
+```tsx
+// Bad — an untyped error the boundary can only show as a generic failure
+if (data.node?.__typename !== "MailingListUpdate") {
+ throw new Error("Update not found");
+}
+```
+
+See [`relay.md`](relay.md) (Node type guards).
+
## Placement guidance
- **Route root** — one boundary so an unhandled failure shows a full-page error instead of a blank screen.
diff --git a/contrib/claude/relay.md b/contrib/claude/relay.md
index 56c3a8919..8f2f49797 100644
--- a/contrib/claude/relay.md
+++ b/contrib/claude/relay.md
@@ -261,6 +261,52 @@ const logoUrl = organization.logo?.downloadUrl ?? undefined; // logo stays optio
Do **not** reach for `@required` to silence nullability on fields that are *genuinely* optional (an avatar, a logo, a description that may be empty). Those keep their nullable type and get a real empty/fallback state. Likewise, never select a field, mark it `@required(action: THROW)`, and rely on the throw as control flow for an expected-empty case — that is an error path, not a branch. And there is no need to annotate fields the schema already declares non-null (`String!`, `Organization!`).
+### Node type guards
+
+A `node(id:)` query resolves to an interface (`Node`), so the page must narrow it
+to the concrete type before use. When the `__typename` is not what the view
+expects, throw a **typed** error the nearest error boundary can map to the right
+state (a not-found page), not a bare `Error`:
+
+```tsx
+// Good — NotFoundError is mapped to the 404 state by the boundary
+import { NotFoundError } from "#/lib/relay/errors";
+
+const data = usePreloadedQuery(updateDetailPageQuery, queryRef);
+if (data.node?.__typename !== "MailingListUpdate") {
+ throw new NotFoundError("Update not found");
+}
+const update = data.node; // narrowed to MailingListUpdate
+```
+
+```tsx
+// Bad — an untyped error only renders a generic failure
+if (data.node?.__typename !== "MailingListUpdate") {
+ throw new Error("Update not found");
+}
+```
+
+This is a client-side invariant, distinct from server field errors (which flow
+through `@throwOnFieldError`; see below). See
+[`error-handling.md`](error-handling.md).
+
+### Field errors (`@throwOnFieldError`)
+
+To contain a **partial** GraphQL failure (data present, one field errored) to the
+component that reads the bad field, annotate the query or fragment with
+`@throwOnFieldError`. The field error then throws at the read site
+(`usePreloadedQuery` / `useFragment`) and is caught by the nearest `ErrorBoundary`
+— on a **fragment** to isolate a section/row, on a **query** for page-level
+fields. This only works when the network layer leaves field-level errors in the
+response (see the portal fetch in [`error-handling.md`](error-handling.md)).
+
+```graphql
+# Good — a field error in this fragment throws at the section's useFragment
+fragment RecentUpdatesSection_trustCenter on TrustCenter @throwOnFieldError {
+ updates(first: 5) { edges { node { id ...MailingListUpdateListItem_update } } }
+}
+```
+
### Refetchable fragments
For lists that support sorting and pagination, use `@refetchable` with `@argumentDefinitions`:
diff --git a/packages/ui/src/v2/ErrorBoundary/ErrorBoundary.tsx b/packages/ui/src/v2/ErrorBoundary/ErrorBoundary.tsx
new file mode 100644
index 000000000..c0dfde27f
--- /dev/null
+++ b/packages/ui/src/v2/ErrorBoundary/ErrorBoundary.tsx
@@ -0,0 +1,55 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+import { Component, type ErrorInfo, type ReactNode } from "react";
+
+export interface ErrorBoundaryProps {
+ children: ReactNode;
+ // A node, or a render function that receives the caught error + a reset fn.
+ fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode);
+ onError?: (error: Error, info: ErrorInfo) => void;
+}
+
+interface ErrorBoundaryState {
+ error: Error | null;
+}
+
+// The single reusable error boundary primitive (the sanctioned use of a class).
+// Generic — works at bootstrap, route, section, or component level; only the
+// placement and the `fallback` differ. See contrib/claude/error-handling.md.
+export class ErrorBoundary extends Component {
+ state: ErrorBoundaryState = { error: null };
+
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
+ return { error };
+ }
+
+ componentDidCatch(error: Error, info: ErrorInfo) {
+ this.props.onError?.(error, info);
+ }
+
+ reset = () => this.setState({ error: null });
+
+ render() {
+ const { error } = this.state;
+ if (error) {
+ const { fallback } = this.props;
+ if (typeof fallback === "function") {
+ return fallback(error, this.reset);
+ }
+ return fallback ?? null;
+ }
+ return this.props.children;
+ }
+}
diff --git a/packages/ui/src/v2/ErrorState/ErrorState.stories.tsx b/packages/ui/src/v2/ErrorState/ErrorState.stories.tsx
new file mode 100644
index 000000000..2196a70cb
--- /dev/null
+++ b/packages/ui/src/v2/ErrorState/ErrorState.stories.tsx
@@ -0,0 +1,73 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+import type { Meta, StoryObj } from "@storybook/react";
+
+import { Button } from "../Button/Button";
+
+import { ErrorState } from "./ErrorState";
+
+const actions = (
+ <>
+
+
+ >
+);
+
+export default {
+ title: "v2/ErrorState",
+ component: ErrorState,
+ args: {
+ code: "404",
+ title: "Page not found",
+ description: "The page you're looking for doesn't exist or may have been moved.",
+ actions,
+ },
+} satisfies Meta;
+
+type Story = StoryObj;
+
+export const Playground: Story = {};
+
+export const NotFound: Story = {
+ args: {
+ code: "404",
+ title: "Page not found",
+ description: "The page you're looking for doesn't exist or may have been moved.",
+ },
+};
+
+export const Forbidden: Story = {
+ args: {
+ code: "403",
+ title: "Access denied",
+ description: "You don't have permission to view this page.",
+ },
+};
+
+export const ServerError: Story = {
+ args: {
+ code: "500",
+ title: "Something went wrong",
+ description: "We hit an unexpected error. Please try again later.",
+ },
+};
+
+export const WithoutCode: Story = {
+ args: {
+ code: undefined,
+ title: "Something went wrong",
+ description: "We hit an unexpected error. Please try again later.",
+ },
+};
diff --git a/packages/ui/src/v2/ErrorState/ErrorState.tsx b/packages/ui/src/v2/ErrorState/ErrorState.tsx
new file mode 100644
index 000000000..b8055be00
--- /dev/null
+++ b/packages/ui/src/v2/ErrorState/ErrorState.tsx
@@ -0,0 +1,59 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+import type { ReactNode } from "react";
+import type { VariantProps } from "tailwind-variants/lite";
+
+import { Heading } from "../typography/Heading";
+import { Text } from "../typography/Text";
+
+import { errorState } from "./variants";
+
+export type ErrorStateProps
+ = VariantProps
+ & {
+ title: string;
+ // Optional status code / label shown above the title (e.g. "404").
+ code?: string;
+ description?: string;
+ // Action slot (primary / secondary buttons). Left to the caller so the
+ // native Button/Link props stay editable. See contrib/claude/ui.md.
+ actions?: ReactNode;
+ className?: string;
+ };
+
+// Presentational full-page error message (Figma "Error message / Page"). Copy
+// and actions come from the caller; this only lays them out.
+export function ErrorState({ code, title, description, actions, fullPage, className }: ErrorStateProps) {
+ const slots = errorState({ fullPage });
+
+ return (
+
+ );
+}
diff --git a/packages/ui/src/v2/ErrorState/variants.ts b/packages/ui/src/v2/ErrorState/variants.ts
new file mode 100644
index 000000000..4c63010c9
--- /dev/null
+++ b/packages/ui/src/v2/ErrorState/variants.ts
@@ -0,0 +1,40 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+import { tv } from "tailwind-variants/lite";
+
+// Centered full-page error message block for portal boundaries (Figma
+// "Error message / Page"). Used for 404 / 403 / 500 / generic page-level errors.
+// root the centering wrapper (page vs in-shell sizing)
+// block the 256px content column (code + title + description + actions)
+// content the stacked text region
+// actions the primary/secondary action row
+export const errorState = tv({
+ slots: {
+ root: "flex w-full items-center justify-center",
+ block: "flex min-w-64 max-w-md flex-col items-center gap-6 text-center",
+ content: "flex w-full flex-col items-center gap-2",
+ actions: "flex items-center justify-center gap-2",
+ },
+ variants: {
+ // Standalone fills the viewport; in-shell sits inside the app chrome.
+ fullPage: {
+ true: { root: "min-h-screen px-6 py-16" },
+ false: { root: "px-6 py-12" },
+ },
+ },
+ defaultVariants: {
+ fullPage: false,
+ },
+});
diff --git a/packages/ui/src/v2/InlineError/InlineError.stories.tsx b/packages/ui/src/v2/InlineError/InlineError.stories.tsx
new file mode 100644
index 000000000..60d3f1cdb
--- /dev/null
+++ b/packages/ui/src/v2/InlineError/InlineError.stories.tsx
@@ -0,0 +1,63 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+import type { Meta, StoryObj } from "@storybook/react";
+
+import { InlineError } from "./InlineError";
+
+const message = "Unable to load content";
+
+export default {
+ title: "v2/InlineError",
+ component: InlineError,
+ args: {
+ layout: "vertical",
+ message,
+ onRetry: () => {},
+ },
+} satisfies Meta;
+
+type Story = StoryObj;
+
+export const Playground: Story = {
+ render: args => (
+
+ ),
+};
diff --git a/packages/ui/src/v2/InlineError/InlineError.tsx b/packages/ui/src/v2/InlineError/InlineError.tsx
new file mode 100644
index 000000000..9cab52030
--- /dev/null
+++ b/packages/ui/src/v2/InlineError/InlineError.tsx
@@ -0,0 +1,47 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+import type { VariantProps } from "tailwind-variants/lite";
+
+import { Button } from "../Button/Button";
+import { Text } from "../typography/Text";
+
+import { inlineError } from "./variants";
+
+export type InlineErrorProps
+ = VariantProps
+ & {
+ message: string;
+ // Retry handler. When omitted, the retry action is hidden.
+ onRetry?: () => void;
+ retryLabel?: string;
+ className?: string;
+ };
+
+// Presentational inline error (Figma "Error message / Inline"). Copy and the
+// retry handler come from the caller; this only lays them out.
+export function InlineError({ layout, message, onRetry, retryLabel = "Retry", className }: InlineErrorProps) {
+ const slots = inlineError({ layout });
+
+ return (
+
+ {message}
+ {onRetry && (
+
+ )}
+
+ );
+}
diff --git a/packages/ui/src/v2/InlineError/variants.ts b/packages/ui/src/v2/InlineError/variants.ts
new file mode 100644
index 000000000..6eb32a97e
--- /dev/null
+++ b/packages/ui/src/v2/InlineError/variants.ts
@@ -0,0 +1,41 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+import { tv } from "tailwind-variants/lite";
+
+// Localized inline error for section / card / list / row load failures (Figma
+// "Error message / Inline").
+// vertical centered column for contained spaces (sections, cards, panels)
+// horizontal compact row for list / table rows
+export const inlineError = tv({
+ slots: {
+ root: "flex w-full gap-2",
+ message: "",
+ },
+ variants: {
+ layout: {
+ vertical: {
+ root: "flex-col items-center justify-center text-center",
+ message: "w-full",
+ },
+ horizontal: {
+ root: "flex-row items-center",
+ message: "flex-1 text-left",
+ },
+ },
+ },
+ defaultVariants: {
+ layout: "vertical",
+ },
+});