Add layered error boundaries to compliance portal

Introduce global, page, and section-level error handling for the
compliance portal so a failure is contained at the smallest possible
scope instead of blanking the whole page.

Add a portal-local Relay fetch that throws only request-level errors
(and always redirects on UNAUTHENTICATED) while leaving field-level
errors in the response, so Relay surfaces them at the reading component
through @throwOnFieldError and the nearest boundary. Add a NotFoundError
for node __typename mismatches mapped to a not-found page.

Ship reusable v2 kit primitives (ErrorBoundary, ErrorState, InlineError)
matching the Figma global/local/inline designs, wire the bootstrap and
route boundaries, and demonstrate section and row boundaries on the home
page. Update the error-handling and relay guides accordingly.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-09 20:32:30 -04:00
parent 4c57d201a4
commit 9cd73816b0
25 changed files with 1018 additions and 30 deletions

View File

@@ -0,0 +1,55 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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<ErrorBoundaryProps, ErrorBoundaryState> {
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;
}
}

View File

@@ -0,0 +1,73 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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 = (
<>
<Button size={2} color="neutral" highContrast>Back to trust center</Button>
<Button size={2} variant="soft" color="neutral">Contact support</Button>
</>
);
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<typeof ErrorState>;
type Story = StoryObj<typeof ErrorState>;
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.",
},
};

View File

@@ -0,0 +1,59 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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<typeof errorState>
& {
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 (
<div className={slots.root({ className })}>
<div className={slots.block()}>
<div className={slots.content()}>
{code && (
<Text size={1} color="gold" align="center">{code}</Text>
)}
<Heading level={1} size={4} weight="medium" align="center" highContrast>
{title}
</Heading>
{description && (
<Text size={2} color="neutral" align="center">{description}</Text>
)}
</div>
{actions && <div className={slots.actions()}>{actions}</div>}
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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,
},
});

View File

@@ -0,0 +1,63 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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<typeof InlineError>;
type Story = StoryObj<typeof InlineError>;
export const Playground: Story = {
render: args => (
<div className="w-96">
<InlineError {...args} />
</div>
),
};
export const Vertical: Story = {
render: () => (
<div className="w-96">
<InlineError layout="vertical" message={message} onRetry={() => {}} />
</div>
),
};
export const Horizontal: Story = {
render: () => (
<div className="w-96">
<InlineError layout="horizontal" message={message} onRetry={() => {}} />
</div>
),
};
export const WithoutRetry: Story = {
render: () => (
<div className="w-96">
<InlineError layout="vertical" message={message} />
</div>
),
};

View File

@@ -0,0 +1,47 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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<typeof inlineError>
& {
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 (
<div className={slots.root({ className })}>
<Text size={2} color="neutral" className={slots.message()}>{message}</Text>
{onRetry && (
<Button size={2} variant="soft" color="neutral" onClick={onRetry}>
{retryLabel}
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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",
},
});