diff --git a/apps/compliance-portal/.gitattributes b/apps/compliance-portal/.gitattributes index 7a1fb9a7a..644ba1f2c 100644 --- a/apps/compliance-portal/.gitattributes +++ b/apps/compliance-portal/.gitattributes @@ -1,2 +1 @@ -__generated__/*.graphql linguist-generated -__generated__/*.js linguist-generated +**/__generated__/** linguist-generated diff --git a/apps/compliance-portal/src/_locales/en-US.json b/apps/compliance-portal/src/_locales/en-US.json index a4a7548e4..3c63de137 100644 --- a/apps/compliance-portal/src/_locales/en-US.json +++ b/apps/compliance-portal/src/_locales/en-US.json @@ -28,5 +28,10 @@ "requests": { "title": "Data Requests", "newRequest": "New Request" + }, + "notFound": { + "title": "Page not found", + "description": "The page you are looking for does not exist or has moved.", + "backHome": "Back to home" } } diff --git a/apps/compliance-portal/src/_locales/fr-FR.json b/apps/compliance-portal/src/_locales/fr-FR.json index 429514070..88c6c2ced 100644 --- a/apps/compliance-portal/src/_locales/fr-FR.json +++ b/apps/compliance-portal/src/_locales/fr-FR.json @@ -28,5 +28,10 @@ "requests": { "title": "Demandes de données", "newRequest": "Nouvelle demande" + }, + "notFound": { + "title": "Page introuvable", + "description": "La page que vous recherchez n'existe pas ou a été déplacée.", + "backHome": "Retour à l'accueil" } } diff --git a/apps/compliance-portal/src/components/Hero/OrganizationContactInfo.tsx b/apps/compliance-portal/src/components/Hero/OrganizationContactInfo.tsx index 7e2f85db4..b0bbf596d 100644 --- a/apps/compliance-portal/src/components/Hero/OrganizationContactInfo.tsx +++ b/apps/compliance-portal/src/components/Hero/OrganizationContactInfo.tsx @@ -16,7 +16,7 @@ import { EnvelopeIcon, GlobeSimpleIcon, MapPinSimpleIcon } from "@phosphor-icons import { Text } from "@probo/ui/src/v2/typography/Text"; import { graphql, useFragment } from "react-relay"; -import { hostnameOf } from "#/lib/url/hostname"; +import { externalHref, hostnameOf } from "#/lib/url/hostname"; import type { OrganizationContactInfo_organization$key } from "./__generated__/OrganizationContactInfo_organization.graphql"; import { organizationContactInfo } from "./variants"; @@ -54,7 +54,7 @@ export function OrganizationContactInfo({ organizationKey }: OrganizationContact {hasWebsite && ( diff --git a/apps/compliance-portal/src/lib/url/hostname.ts b/apps/compliance-portal/src/lib/url/hostname.ts index 6b7e69696..875632256 100644 --- a/apps/compliance-portal/src/lib/url/hostname.ts +++ b/apps/compliance-portal/src/lib/url/hostname.ts @@ -12,12 +12,25 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. +// Prepend https:// when a URL carries no http(s) scheme, so a protocol-less +// value (e.g. "blaxel.ai") parses as absolute instead of being treated as a +// relative path. +function withHttpScheme(url: string): string { + return /^https?:\/\//i.test(url) ? url : `https://${url}`; +} + // Show only the hostname for a URL (e.g. "https://blaxel.ai/x" -> "blaxel.ai"), // falling back to the raw value when it cannot be parsed. export function hostnameOf(url: string): string { try { - return new URL(url).host; + return new URL(withHttpScheme(url)).hostname; } catch { return url; } } + +// Build a safe absolute href for an external link, normalizing the scheme so a +// protocol-less value does not resolve as a relative link. +export function externalHref(url: string): string { + return withHttpScheme(url); +} diff --git a/apps/compliance-portal/src/pages/NotFoundPage.tsx b/apps/compliance-portal/src/pages/NotFoundPage.tsx new file mode 100644 index 000000000..3a5f71347 --- /dev/null +++ b/apps/compliance-portal/src/pages/NotFoundPage.tsx @@ -0,0 +1,42 @@ +// 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 { Link } from "@probo/ui/src/v2/Button/Link"; +import { Heading } from "@probo/ui/src/v2/typography/Heading"; +import { Text } from "@probo/ui/src/v2/typography/Text"; +import { useTranslation } from "react-i18next"; + +import { HeaderBand } from "#/components/HeaderBand/HeaderBand"; + +// Catch-all page for portal paths that match no route, so an unknown URL renders +// an explicit not-found state inside the layout instead of an empty body. +export default function NotFoundPage() { + const { t } = useTranslation(); + + return ( + +
+ + {t("notFound.title")} + + + {t("notFound.description")} + + + {t("notFound.backHome")} + +
+
+ ); +} diff --git a/apps/compliance-portal/src/routes.tsx b/apps/compliance-portal/src/routes.tsx index e97dfad47..996602e12 100644 --- a/apps/compliance-portal/src/routes.tsx +++ b/apps/compliance-portal/src/routes.tsx @@ -47,6 +47,10 @@ const routes = [ path: "requests", Component: lazy(() => import("#/pages/RequestsPage")), }, + { + path: "*", + Component: lazy(() => import("#/pages/NotFoundPage")), + }, ], }, ] satisfies AppRoute[]; diff --git a/contrib/claude/forms.md b/contrib/claude/forms.md index 475fdcc24..acf322e6c 100644 --- a/contrib/claude/forms.md +++ b/contrib/claude/forms.md @@ -93,10 +93,10 @@ export function CreateMeasureForm({ onValid }: CreateMeasureFormProps) {
{ - const result = schema.safeParse(Object.fromEntries(formData)); + onFormSubmit={(formValues) => { + const result = schema.safeParse(formValues); if (!result.success) { - setErrors(z.flattenError(result.error).fieldErrors); + setErrors(result.error.flatten().fieldErrors); return; } onValid(result.data); diff --git a/contrib/claude/permissions.md b/contrib/claude/permissions.md index bb00bb95e..e0f0c5d3f 100644 --- a/contrib/claude/permissions.md +++ b/contrib/claude/permissions.md @@ -23,12 +23,15 @@ const documentListItemFragment = graphql` title canUpdate: permission(action: "core:document:update") canDelete: permission(action: "core:document:delete") + ...EditDocumentDialog_document } `; ``` Colocate the permission with the action it gates — never drill a `canDelete` boolean down as a prop from a parent (the same data-as-props rule as everywhere else; see [`react-components.md`](react-components.md#props-are-for-configuration-and-composition-not-data)). +Spread a child's fragment (`...EditDocumentDialog_document`) when you forward the node to that child as a fragment key. `useFragment` returns plain data, but Relay keeps the spread fragment refs on it, so the resolved `document` doubles as the key the child's own `useFragment` expects. Without the spread, `documentKey={document}` would hand the child masked data with no ref and fail at runtime (see [`relay.md`](relay.md)). + ## Gate the action on the boolean Read the boolean via `useFragment` and gate the control. Default to **hiding** an action the user cannot perform; **disable** (with an explanatory tooltip) only when the action's *absence* would be confusing. diff --git a/contrib/claude/ui.md b/contrib/claude/ui.md index 2ca777b82..d621ed190 100644 --- a/contrib/claude/ui.md +++ b/contrib/claude/ui.md @@ -295,13 +295,13 @@ Components fall into two categories: **primitives** and **compound** components. ### Primitives -**Primitives** (`Text`, `Image`, form inputs, layout helpers, `ListItem`) are self-contained — they render a single semantic element with their own styling. A primitive **is its own shell**: there is no separate shell wrapper. Each primitive has a paired skeleton (`TextSkeleton`, `ImageSkeleton`) that matches its dimensions. +**Primitives** (`Text`, `Avatar`, `Badge`, form inputs, layout helpers) are self-contained — they render a single semantic element with their own styling. A primitive **is its own shell**: there is no separate shell wrapper. Each primitive has a paired skeleton (`TextSkeleton`, `AvatarSkeleton`) that matches its dimensions. ### Compound components -**Compound components** (`ImageCard`, …) assemble multiple primitives into a larger region. When logic (state, effects, data) lives inside the top-level component, a **shell** separates layout from behavior: +**Compound components** (`Card`, `Dropdown`, …) assemble multiple primitives into a larger region. When logic (state, effects, data) lives inside the top-level component, a **shell** separates layout from behavior: -- **Shell** — pure layout frame that accepts region props (`image`, `text`, …) as `ReactNode` and applies `tv` slot classes. No state, no effects, no data. +- **Shell** — pure layout frame that accepts region props (`media`, `text`, …) as `ReactNode` and applies `tv` slot classes. No state, no effects, no data. - **Root** — owns the logic and renders the shell, passing primitives into its region props. - **Skeleton** — reuses the **same shell** with skeleton primitives, so the loading placeholder is structurally identical without pulling in the logic graph. diff --git a/packages/relay/src/useMutation.ts b/packages/relay/src/useMutation.ts index 6a116d834..ac94d4c3d 100644 --- a/packages/relay/src/useMutation.ts +++ b/packages/relay/src/useMutation.ts @@ -81,11 +81,24 @@ export function createUseMutation(useNotifier: () => MutationNotifier) { ); } + function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); + } + return new Promise((resolve, reject) => { commit({ ...config, onCompleted: (response, errors) => { - config.onCompleted?.(response, errors); + // A throwing caller callback must still settle the wrapper promise, + // otherwise `await mutate()` would hang forever. + try { + config.onCompleted?.(response, errors); + } catch (callbackError) { + const error = toError(callbackError); + notifyError(error); + reject(error); + return; + } if (errors && errors.length > 0) { const [payloadError] = errors; notifyError(payloadError); @@ -102,7 +115,13 @@ export function createUseMutation(useNotifier: () => MutationNotifier) { resolve(response); }, onError: (error) => { - config.onError?.(error); + // Swallow a throwing caller callback so the original mutation error + // still flows through to the notifier and the rejection. + try { + config.onError?.(error); + } catch { + // Intentionally ignored: the mutation error below is authoritative. + } notifyError(error); reject(error); }, diff --git a/packages/ui/src/v2/Avatar/AvatarSkeleton.tsx b/packages/ui/src/v2/Avatar/AvatarSkeleton.tsx index 39d9ae3e3..2e34ccbf9 100644 --- a/packages/ui/src/v2/Avatar/AvatarSkeleton.tsx +++ b/packages/ui/src/v2/Avatar/AvatarSkeleton.tsx @@ -24,5 +24,5 @@ export type AvatarSkeletonProps = Omit, "children"> & Var export function AvatarSkeleton(props: AvatarSkeletonProps) { const { size, radius, className, ...rest } = props; - return ; + return ; } diff --git a/packages/ui/src/v2/Badge/BadgeSkeleton.tsx b/packages/ui/src/v2/Badge/BadgeSkeleton.tsx index 9eabaf544..1c857b255 100644 --- a/packages/ui/src/v2/Badge/BadgeSkeleton.tsx +++ b/packages/ui/src/v2/Badge/BadgeSkeleton.tsx @@ -24,5 +24,5 @@ export type BadgeSkeletonProps = Omit, "children"> & Vari export function BadgeSkeleton(props: BadgeSkeletonProps) { const { size, className, ...rest } = props; - return ; + return ; } diff --git a/packages/ui/src/v2/Button/Button.tsx b/packages/ui/src/v2/Button/Button.tsx index bba2ce54b..c57b5aef8 100644 --- a/packages/ui/src/v2/Button/Button.tsx +++ b/packages/ui/src/v2/Button/Button.tsx @@ -32,7 +32,7 @@ export type ButtonProps // or router link (Link) are separate components. See contrib/claude/ui.md. export function Button(props: ButtonProps) { const { - size, variant, color, highContrast, className, + size, variant, color, highContrast, active, className, iconStart, iconEnd, loading = false, disabled, type = "button", children, ...rest } = props; @@ -41,18 +41,12 @@ export function Button(props: ButtonProps) { type={type} disabled={disabled || loading} aria-busy={loading || undefined} - className={button({ size, variant, color, highContrast, className })} + className={button({ size, variant, color, highContrast, active, className })} {...rest} > - {loading - ? - : ( - <> - {iconStart} - {children} - {iconEnd} - - )} + {loading ? : iconStart} + {children} + {iconEnd} ); } diff --git a/packages/ui/src/v2/Button/ButtonSkeleton.tsx b/packages/ui/src/v2/Button/ButtonSkeleton.tsx index 35446477d..c69bb901a 100644 --- a/packages/ui/src/v2/Button/ButtonSkeleton.tsx +++ b/packages/ui/src/v2/Button/ButtonSkeleton.tsx @@ -24,5 +24,5 @@ export type ButtonSkeletonProps = Omit, "children"> & Var export function ButtonSkeleton(props: ButtonSkeletonProps) { const { size, className, ...rest } = props; - return ; + return ; } diff --git a/packages/ui/src/v2/Callout/CalloutSkeleton.tsx b/packages/ui/src/v2/Callout/CalloutSkeleton.tsx index b283ae755..7bf76d6b9 100644 --- a/packages/ui/src/v2/Callout/CalloutSkeleton.tsx +++ b/packages/ui/src/v2/Callout/CalloutSkeleton.tsx @@ -23,5 +23,5 @@ export type CalloutSkeletonProps = Omit, "children"> & Var export function CalloutSkeleton(props: CalloutSkeletonProps) { const { size, className, ...rest } = props; - return
; + return
; } diff --git a/packages/ui/src/v2/Card/CardSkeleton.tsx b/packages/ui/src/v2/Card/CardSkeleton.tsx index a58fbddeb..ac72ff7ed 100644 --- a/packages/ui/src/v2/Card/CardSkeleton.tsx +++ b/packages/ui/src/v2/Card/CardSkeleton.tsx @@ -23,5 +23,5 @@ export type CardSkeletonProps = Omit, "children"> & Varian export function CardSkeleton(props: CardSkeletonProps) { const { size, className, ...rest } = props; - return
; + return
; } diff --git a/packages/ui/src/v2/IconButton/IconButtonSkeleton.tsx b/packages/ui/src/v2/IconButton/IconButtonSkeleton.tsx index d38f7d8eb..a5e21a355 100644 --- a/packages/ui/src/v2/IconButton/IconButtonSkeleton.tsx +++ b/packages/ui/src/v2/IconButton/IconButtonSkeleton.tsx @@ -24,5 +24,5 @@ export type IconButtonSkeletonProps = Omit, "children"> & export function IconButtonSkeleton(props: IconButtonSkeletonProps) { const { size, className, ...rest } = props; - return ; + return ; } diff --git a/packages/ui/src/v2/typography/HeadingSkeleton.tsx b/packages/ui/src/v2/typography/HeadingSkeleton.tsx index 8917213fb..882dc5478 100644 --- a/packages/ui/src/v2/typography/HeadingSkeleton.tsx +++ b/packages/ui/src/v2/typography/HeadingSkeleton.tsx @@ -26,7 +26,7 @@ export function HeadingSkeleton(props: HeadingSkeletonProps) { const { size, className, ...rest } = props; return ( - + {"\u00A0"} ); diff --git a/packages/ui/src/v2/typography/TextSkeleton.tsx b/packages/ui/src/v2/typography/TextSkeleton.tsx index 55d28ef1d..5cc492833 100644 --- a/packages/ui/src/v2/typography/TextSkeleton.tsx +++ b/packages/ui/src/v2/typography/TextSkeleton.tsx @@ -26,7 +26,7 @@ export function TextSkeleton(props: TextSkeletonProps) { const { size, className, ...rest } = props; return ( - + {"\u00A0"} );