Make compliance portal layout responsive

Add a burger drawer below md, fix document-flow
scrolling so PoweredBy sits after content, and
collapse home grids, list rows, toolbars, and
dialogs for smaller viewports. PDF viewer fits
width; updates skeleton mirrors stacked meta.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-20 16:39:46 +02:00
parent 3c5d8c6265
commit 89b780c6aa
46 changed files with 570 additions and 132 deletions

View File

@@ -2,6 +2,9 @@
"topBar": {
"tagline": "Compliance Portal",
"getAccess": "Get Access",
"openMenu": "Open menu",
"closeMenu": "Close menu",
"menuTitle": "Menu",
"nav": {
"documents": "Documents",
"subprocessors": "Subprocessors",

View File

@@ -2,6 +2,9 @@
"topBar": {
"tagline": "Portail de conformité",
"getAccess": "Obtenir l'accès",
"openMenu": "Ouvrir le menu",
"closeMenu": "Fermer le menu",
"menuTitle": "Menu",
"nav": {
"documents": "Documents",
"subprocessors": "Sous-traitants",

View File

@@ -25,10 +25,10 @@ import { tv } from "tailwind-variants/lite";
// the card surface, with centered media on top, above a left-aligned body.
export const backdropCard = tv({
slots: {
header: "relative flex w-full items-center overflow-hidden p-8",
header: "relative flex w-full items-center overflow-hidden p-8 max-md:p-4",
blurBackdrop: "pointer-events-none absolute inset-0 size-full scale-150 object-cover opacity-10 blur-lg",
backdrop: "pointer-events-none absolute inset-0",
backdropFade: "pointer-events-none absolute inset-0 bg-linear-to-b from-sand-1/0 to-sand-1",
body: "flex flex-col gap-2 px-8 pb-8",
body: "flex flex-col gap-2 px-8 pb-8 max-md:px-4 max-md:pb-4",
},
});

View File

@@ -30,7 +30,7 @@ export interface ComplianceArticleItemProps {
title: ReactNode;
// Optional accent sub-label below the title.
eyebrow?: ReactNode;
// Right-aligned metadata (e.g. relative time).
// Trailing metadata on desktop; stacks under the title on small screens.
meta?: ReactNode;
}
@@ -43,20 +43,22 @@ export function ComplianceArticleItem({ icon, title, eyebrow, meta }: Compliance
<div className={slots.root()}>
<span className={slots.icon()}>{icon}</span>
<div className={slots.content()}>
<Text size={2} weight="medium" color="neutral" highContrast>
{title}
</Text>
{eyebrow != null && (
<Text size={1} color="gold">
{eyebrow}
<div className={slots.text()}>
<Text size={2} weight="medium" color="neutral" highContrast className={slots.title()}>
{title}
</Text>
{eyebrow != null && (
<Text size={1} color="gold">
{eyebrow}
</Text>
)}
</div>
{meta != null && (
<Text size={1} color="faint" className={slots.meta()}>
{meta}
</Text>
)}
</div>
{meta != null && (
<Text size={1} color="faint" className={slots.meta()}>
{meta}
</Text>
)}
</div>
);
}

View File

@@ -23,7 +23,7 @@ import { TextSkeleton } from "@probo/ui/src/v2/typography/TextSkeleton";
import { complianceArticleItem } from "./variants";
// Loading placeholder paired with ComplianceArticleItem: same row layout with
// a pulse icon and skeleton text.
// a pulse icon and skeleton text (meta stacks under the title on max-sm).
export function ComplianceArticleItemSkeleton() {
const slots = complianceArticleItem();
@@ -31,9 +31,11 @@ export function ComplianceArticleItemSkeleton() {
<div className={slots.root()} aria-hidden>
<div className={slots.iconPlaceholder()} />
<div className={slots.content()}>
<TextSkeleton size={2} className="w-48" />
<div className={slots.text()}>
<TextSkeleton size={2} className="w-48 max-sm:w-40" />
</div>
<TextSkeleton size={1} className={`w-20 ${slots.meta()}`} />
</div>
<TextSkeleton size={1} className="w-20 shrink-0" />
</div>
);
}

View File

@@ -21,15 +21,16 @@
import { tv } from "tailwind-variants/lite";
// Compliance article list row (Figma "Compliance Article Item"): a leading
// icon, a title (+ optional eyebrow), and right-aligned meta. Designed to sit
// inside a list container that draws the dividers (e.g. `divide-y`), so rows
// stay divider-agnostic whether or not each is wrapped in a link. Slots are
// icon, a title (+ optional eyebrow), and meta. Desktop keeps meta on the
// trailing edge; mobile stacks it under the title with a tight gap. Slots are
// shared by the row and its skeleton.
export const complianceArticleItem = tv({
slots: {
root: "flex w-full items-center gap-4 px-8 py-4",
root: "flex w-full items-center gap-4 px-8 py-4 max-md:px-4",
icon: "flex size-6 shrink-0 items-center justify-center text-gold-9 [&_svg]:size-full",
content: "flex min-w-0 flex-1 flex-col gap-1",
content: "flex min-w-0 flex-1 items-center gap-4 max-sm:flex-col max-sm:items-start max-sm:gap-0.5",
text: "flex min-w-0 flex-1 flex-col gap-0.5",
title: "truncate",
meta: "shrink-0",
iconPlaceholder: "size-6 shrink-0 animate-pulse rounded-2 bg-sand-3",
},

View File

@@ -80,7 +80,7 @@ function ComplianceFrameworksSectionContent({ trustCenterKey }: ComplianceFramew
return (
<HomeSection title={t("home.sections.compliance")}>
<div className="grid grid-cols-6 gap-4">
<div className="grid grid-cols-6 gap-4 max-lg:grid-cols-3 max-sm:grid-cols-2">
{frameworks.map(framework => (
<ComplianceFrameworkListItem key={framework.id} complianceFrameworkKey={framework} />
))}

View File

@@ -31,7 +31,7 @@ export function ComplianceFrameworksSectionSkeleton() {
<div className={slots.header()}>
<TextSkeleton size={2} className="w-24" />
</div>
<div className="grid grid-cols-6 gap-4">
<div className="grid grid-cols-6 gap-4 max-lg:grid-cols-3 max-sm:grid-cols-2">
{Array.from({ length: 4 }, (_, index) => (
<MediaTileSkeleton key={index} />
))}

View File

@@ -25,7 +25,7 @@ import { tv } from "tailwind-variants/lite";
// Hero and the page headers so the band is defined once.
export const headerBand = tv({
slots: {
band: "flex w-full flex-col items-center border-b border-sand-a3 bg-sand-1 px-8",
band: "flex w-full flex-col items-center border-b border-sand-a3 bg-sand-1 px-8 max-md:px-4",
inner: "w-full max-w-5xl",
},
variants: {

View File

@@ -36,8 +36,8 @@ export const hero = tv({
export const organizationContactInfo = tv({
slots: {
// Only a top divider gap; the band's py-8 provides the bottom spacing.
root: "flex w-full items-center gap-6 border-t border-sand-a2 pt-4",
item: "flex items-center gap-2 text-sand-11 [&_svg]:size-5",
link: "flex items-center gap-2 text-sand-11 hover:underline [&_svg]:size-5",
root: "flex w-full flex-wrap items-center gap-x-6 gap-y-2 border-t border-sand-a2 pt-4 max-md:flex-col max-md:items-start",
item: "flex min-w-0 items-center gap-2 text-sand-11 [&_svg]:size-5 [&_svg]:shrink-0 [&>*]:min-w-0 [&>*:not(svg)]:break-words",
link: "flex min-w-0 items-center gap-2 text-sand-11 hover:underline [&_svg]:size-5 [&_svg]:shrink-0 [&>*]:min-w-0 [&>*:not(svg)]:break-all",
},
});

View File

@@ -25,7 +25,7 @@ import { tv } from "tailwind-variants/lite";
export const pageHeader = tv({
slots: {
content: "flex w-full flex-col gap-2",
titleRow: "flex w-full items-center justify-between gap-4",
titleRow: "flex w-full items-center justify-between gap-4 max-sm:flex-col max-sm:items-stretch",
// Muted item count appended to the title (e.g. "Documents (3)").
count: "font-light text-sand-8",
},

View File

@@ -89,7 +89,7 @@ function RecentUpdatesSectionContent({ trustCenterKey }: RecentUpdatesSectionPro
>
<div className="relative overflow-hidden rounded-5 border border-sand-3 bg-sand-1">
<div aria-hidden className="pointer-events-none absolute inset-0" style={dotPatternStyle} />
<div aria-hidden className="pointer-events-none absolute inset-0 bg-linear-to-r from-sand-1/0 to-sand-1 to-[96px]" />
<div aria-hidden className="pointer-events-none absolute inset-0 bg-linear-to-r from-sand-1/0 to-sand-1 to-[96px] max-sm:to-[48px]" />
<div className="relative divide-y divide-sand-a2">
{updates.map(update => (
// Rows share fate (one connection query), so a row's field error

View File

@@ -35,7 +35,7 @@ export function RecentUpdatesSectionSkeleton() {
</div>
<div className="relative overflow-hidden rounded-5 border border-sand-3 bg-sand-1">
<div aria-hidden className="pointer-events-none absolute inset-0" style={dotPatternStyle} />
<div aria-hidden className="pointer-events-none absolute inset-0 bg-linear-to-r from-sand-1/0 to-sand-1 to-[96px]" />
<div aria-hidden className="pointer-events-none absolute inset-0 bg-linear-to-r from-sand-1/0 to-sand-1 to-[96px] max-sm:to-[48px]" />
<div className="relative divide-y divide-sand-a2">
{Array.from({ length: 5 }, (_, index) => (
<ComplianceArticleItemSkeleton key={index} />

View File

@@ -27,6 +27,6 @@ export const securityCommitments = tv({
root: "flex w-full flex-col gap-8 py-8",
group: "flex w-full flex-col gap-4",
groupHeader: "flex w-full flex-col gap-2",
grid: "grid grid-cols-3 gap-4",
grid: "grid grid-cols-3 gap-4 max-lg:grid-cols-2 max-md:grid-cols-1",
},
});

View File

@@ -31,20 +31,16 @@ import { buildRequestAllContinueUrl } from "#/lib/auth/continueUrl";
import { useSignInDialog } from "#/lib/auth/signInDialogContext";
import type { TopBar_query$key } from "./__generated__/TopBar_query.graphql";
import { TOP_BAR_NAV_ITEMS } from "./navItems";
import { TopBarMobileNav } from "./TopBarMobileNav";
import { TopBarUserMenu } from "./TopBarUserMenu";
import { topBar } from "./variants";
const NAV_ITEMS = [
{ to: "/documents", labelKey: "topBar.nav.documents" },
{ to: "/subprocessors", labelKey: "topBar.nav.subprocessors" },
{ to: "/updates", labelKey: "topBar.nav.updates" },
{ to: "/requests", labelKey: "topBar.nav.requests" },
] as const;
const topBarFragment = graphql`
fragment TopBar_query on Query {
viewer {
...TopBarUserMenu_identity
...TopBarMobileNav_identity
}
currentTrustCenter @required(action: THROW) {
themedLogoUrl
@@ -88,10 +84,10 @@ export function TopBar({ queryKey }: TopBarProps) {
fallback={organizationName.charAt(0) || "?"}
className={slots.logo()}
/>
<Text size={2} weight="medium" color="neutral" highContrast>
<Text size={2} weight="medium" color="neutral" highContrast className={slots.brandName()}>
{organizationName}
</Text>
<Text size={2} color="neutral">
<Text size={2} color="neutral" className={slots.tagline()}>
{t("topBar.tagline")}
</Text>
</RouterLink>
@@ -99,7 +95,7 @@ export function TopBar({ queryKey }: TopBarProps) {
<div className={slots.spacer()} />
<nav className={slots.nav()}>
{NAV_ITEMS.map(item => (
{TOP_BAR_NAV_ITEMS.map(item => (
<Link
key={item.to}
to={item.to}
@@ -125,6 +121,8 @@ export function TopBar({ queryKey }: TopBarProps) {
)
: <TopBarUserMenu identityKey={data.viewer} />}
</nav>
<TopBarMobileNav identityKey={data.viewer ?? null} />
</div>
</header>
);

View File

@@ -0,0 +1,212 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { Drawer } from "@base-ui/react/drawer";
import {
BellIcon,
BellRingingIcon,
ListIcon,
LockSimpleIcon,
SignOutIcon,
XIcon,
} from "@phosphor-icons/react";
import { Button } from "@probo/ui/src/v2/Button/Button";
import { Link } from "@probo/ui/src/v2/Button/Link";
import { IconButton } from "@probo/ui/src/v2/IconButton/IconButton";
import { Text } from "@probo/ui/src/v2/typography/Text";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { graphql, useFragment } from "react-relay";
import { useLocation } from "react-router";
import { buildRequestAllContinueUrl } from "#/lib/auth/continueUrl";
import { useSignInDialog } from "#/lib/auth/signInDialogContext";
import { useSignOut } from "#/lib/auth/useSignOut";
import { useSubscribeDialog } from "#/lib/mailingList/subscribeDialogContext";
import type { TopBarMobileNav_identity$key } from "./__generated__/TopBarMobileNav_identity.graphql";
import { TOP_BAR_NAV_ITEMS } from "./navItems";
import { topBar, topBarMobileNav } from "./variants";
const topBarMobileNavFragment = graphql`
fragment TopBarMobileNav_identity on Identity {
fullName
email
}
`;
interface TopBarMobileNavProps {
identityKey: TopBarMobileNav_identity$key | null;
}
function isActive(pathname: string, to: string): boolean {
return pathname === to || pathname.startsWith(`${to}/`);
}
// Burger trigger + right-edge drawer listing the same nav and account actions as
// the desktop TopBar.
export function TopBarMobileNav({ identityKey }: TopBarMobileNavProps) {
const { t } = useTranslation();
const { pathname } = useLocation();
const { openSignIn } = useSignInDialog();
const { openSubscribe, isSubscribed, unsubscribe, isUnsubscribing } = useSubscribeDialog();
const [signOut, isSigningOut] = useSignOut();
const [open, setOpen] = useState(false);
const identity = useFragment(topBarMobileNavFragment, identityKey);
const barSlots = topBar();
const slots = topBarMobileNav();
const displayName = identity
? (identity.fullName.trim() || identity.email)
: null;
const close = () => setOpen(false);
return (
<Drawer.Root open={open} onOpenChange={setOpen} swipeDirection="right">
<div className={barSlots.menuTrigger()}>
<Drawer.Trigger
render={(
<IconButton
variant="ghost"
color="neutral"
size={2}
aria-label={t("topBar.openMenu")}
>
<ListIcon />
</IconButton>
)}
/>
</div>
<Drawer.Portal>
<Drawer.Backdrop className={slots.backdrop()} />
<Drawer.Viewport className={slots.viewport()}>
<Drawer.Popup className={slots.popup()}>
<Drawer.Content className={slots.content()}>
<div className={slots.header()}>
<Drawer.Title className={slots.title()}>
{t("topBar.menuTitle")}
</Drawer.Title>
<Drawer.Close
render={(
<IconButton
variant="ghost"
color="neutral"
size={2}
aria-label={t("topBar.closeMenu")}
>
<XIcon />
</IconButton>
)}
/>
</div>
<nav className={slots.nav()}>
{TOP_BAR_NAV_ITEMS.map(item => (
<Link
key={item.to}
to={item.to}
variant="ghost"
color="neutral"
size={3}
active={isActive(pathname, item.to)}
className="w-full justify-start"
onClick={close}
>
{t(item.labelKey)}
</Link>
))}
</nav>
<div className={slots.actions()}>
{identity == null
? (
<Button
variant="solid"
color="neutral"
highContrast
size={3}
className="w-full"
iconStart={<LockSimpleIcon />}
onClick={() => {
close();
openSignIn({ continueTo: buildRequestAllContinueUrl() });
}}
>
{t("topBar.getAccess")}
</Button>
)
: (
<>
{displayName
? (
<div className="flex flex-col gap-0.5 px-1 pb-2">
<Text size={2} weight="medium" color="neutral" highContrast className="truncate">
{displayName}
</Text>
<Text size={1} color="faint" className="truncate">
{identity.email}
</Text>
</div>
)
: null}
<Button
variant="ghost"
color={isSubscribed ? "green" : "gold"}
size={3}
className="w-full justify-start"
iconStart={isSubscribed ? <BellRingingIcon /> : <BellIcon />}
disabled={isUnsubscribing}
onClick={() => {
close();
if (isSubscribed) {
void unsubscribe();
return;
}
openSubscribe();
}}
>
{isSubscribed ? t("userMenu.subscribed") : t("userMenu.subscribe")}
</Button>
<Button
variant="ghost"
color="red"
size={3}
className="w-full justify-start"
iconStart={<SignOutIcon />}
disabled={isSigningOut}
onClick={() => {
close();
void signOut();
}}
>
{t("userMenu.signOut")}
</Button>
</>
)}
</div>
</Drawer.Content>
</Drawer.Popup>
</Drawer.Viewport>
</Drawer.Portal>
</Drawer.Root>
);
}

View File

@@ -43,7 +43,8 @@ export function TopBarSkeleton() {
<div className={slots.inner()}>
<div className={slots.brand()}>
<AvatarSkeleton size={1} radius="small" />
<TextSkeleton size={2} className="w-24" />
<TextSkeleton size={2} className={`w-24 ${slots.brandName()}`} />
<TextSkeleton size={2} className={`w-28 ${slots.tagline()}`} />
</div>
<div className={slots.spacer()} />
@@ -54,6 +55,10 @@ export function TopBarSkeleton() {
))}
<ButtonSkeleton size={2} />
</nav>
<div className={slots.menuTrigger()}>
<div className="size-8 animate-pulse rounded-2 bg-sand-3" />
</div>
</div>
</div>
);

View File

@@ -76,10 +76,10 @@ export function TopBarUserMenu({ identityKey }: TopBarUserMenuProps) {
radius="small"
fallback={<UserIcon />}
/>
<Text size={2} weight="medium" color="neutral" highContrast>
<Text size={2} weight="medium" color="neutral" highContrast className="max-w-36 truncate">
{displayName}
</Text>
<CaretDownIcon className="size-4 text-sand-11" />
<CaretDownIcon className="size-4 shrink-0 text-sand-11" />
</button>
)}
/>

View File

@@ -0,0 +1,26 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
export const TOP_BAR_NAV_ITEMS = [
{ to: "/documents", labelKey: "topBar.nav.documents" },
{ to: "/subprocessors", labelKey: "topBar.nav.subprocessors" },
{ to: "/updates", labelKey: "topBar.nav.updates" },
{ to: "/requests", labelKey: "topBar.nav.requests" },
] as const;

View File

@@ -21,15 +21,20 @@
import { tv } from "tailwind-variants/lite";
// Trust Center top navigation bar. Slots are shared by the live TopBar and its
// skeleton so the loading placeholder is structurally identical.
// skeleton so the loading placeholder is structurally identical. Desktop-first:
// unprefixed classes are the desktop layout; max-md: collapses into the burger.
export const topBar = tv({
slots: {
bar: "flex h-14 items-center bg-sand-1 px-8",
inner: "mx-auto flex w-full max-w-5xl items-center gap-10",
brand: "flex items-center gap-2",
bar: "flex h-14 items-center bg-sand-1 px-8 max-md:px-4",
inner: "mx-auto flex w-full max-w-5xl items-center gap-10 max-md:gap-3",
brand: "flex min-w-0 items-center gap-2",
brandName: "truncate",
tagline: "max-md:hidden",
logo: "shrink-0",
spacer: "h-px flex-1",
nav: "flex items-center gap-1",
nav: "flex items-center gap-1 max-md:hidden",
// Wrapper (not the IconButton) so kit `inline-flex` cannot override `hidden`.
menuTrigger: "hidden max-md:block",
},
});
@@ -42,3 +47,26 @@ export const topBarUserMenuTrigger = tv({
"focus-visible:ring-2 focus-visible:ring-sand-8 focus-visible:ring-offset-1 focus-visible:ring-offset-sand-1",
],
});
// Right-edge mobile navigation drawer (Base UI Drawer).
export const topBarMobileNav = tv({
slots: {
backdrop: [
"fixed inset-0 z-50 bg-sand-12/40",
"transition-opacity duration-200",
"data-starting-style:opacity-0 data-ending-style:opacity-0",
],
viewport: "fixed inset-0 z-50 flex justify-end",
popup: [
"relative flex h-full w-[min(20rem,100%)] flex-col bg-sand-1 shadow-6 outline-none",
"[transform:translateX(var(--drawer-swipe-movement-x,0px))]",
"transition-transform duration-200",
"data-starting-style:translate-x-full data-ending-style:translate-x-full",
],
content: "flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4",
header: "flex items-center justify-between gap-3",
title: "text-4 font-medium text-sand-12",
nav: "flex flex-col gap-1",
actions: "mt-auto flex flex-col gap-2 border-t border-sand-a3 pt-4",
},
});

View File

@@ -78,7 +78,7 @@ function TrustedBySectionContent({ trustCenterKey }: TrustedBySectionProps) {
return (
<HomeSection title={t("home.sections.trustedBy")}>
<div className="grid grid-cols-6 gap-4">
<div className="grid grid-cols-6 gap-4 max-lg:grid-cols-3 max-sm:grid-cols-2">
{references.map(reference => (
<TrustCenterReferenceListItem key={reference.id} referenceKey={reference} />
))}

View File

@@ -31,7 +31,7 @@ export function TrustedBySectionSkeleton() {
<div className={slots.header()}>
<TextSkeleton size={2} className="w-20" />
</div>
<div className="grid grid-cols-6 gap-4">
<div className="grid grid-cols-6 gap-4 max-lg:grid-cols-3 max-sm:grid-cols-2">
{Array.from({ length: 6 }, (_, index) => (
<MediaTileSkeleton key={index} variant="logo" />
))}

View File

@@ -64,7 +64,7 @@ export function HomePage({ queryRef }: HomePageProps) {
>
<OrganizationContactInfo organizationKey={organization} />
</Hero>
<div className="flex w-full flex-col items-center px-8">
<div className="flex w-full flex-col items-center px-8 max-md:px-4">
<div className="flex w-full max-w-5xl flex-col">
<ComplianceFrameworksSection trustCenterKey={currentTrustCenter} />
<SecurityCommitmentsSection trustCenterKey={currentTrustCenter} />

View File

@@ -28,7 +28,7 @@ export function HomePageSkeleton() {
return (
<>
<HeroSkeleton />
<div className="flex w-full flex-col items-center px-8">
<div className="flex w-full flex-col items-center px-8 max-md:px-4">
<div className="flex w-full max-w-5xl flex-col">
<ComplianceFrameworksSectionSkeleton />
<SecurityCommitmentsSectionSkeleton />

View File

@@ -21,7 +21,7 @@
import { useTranslation } from "react-i18next";
import type { PreloadedQuery } from "react-relay";
import { graphql, usePreloadedQuery } from "react-relay";
import { Outlet } from "react-router";
import { Outlet, useMatch } from "react-router";
import { PoweredBy } from "#/components/PoweredBy/PoweredBy";
import { TopBar } from "#/components/TopBar/TopBar";
@@ -48,22 +48,35 @@ interface MainLayoutProps {
export function MainLayout({ queryRef }: MainLayoutProps) {
const { t } = useTranslation();
const data = usePreloadedQuery<MainLayoutQuery>(mainLayoutQuery, queryRef);
const isDocumentViewer = useMatch("documents/:alias") != null;
// Resume a deferred "request access" once the user lands back authenticated.
useResumeAccessRequest(data.viewer != null);
// Document viewer fills the viewport under the TopBar and scrolls its own
// stage; every other page uses normal document flow so the footer sits after
// content (and at the bottom of short pages via flex-1 main).
return (
// Bound the shell to the viewport so the TopBar and footer stay fixed and the
// page area scrolls on its own. Pages that fill the height (the document
// viewer) then scroll their own body while their toolbar stays put.
<SignInDialogProvider>
<SubscribeDialogProvider queryKey={data}>
<div className="flex h-dvh flex-col bg-sand-2">
<div
className={
isDocumentViewer
? "flex h-dvh flex-col bg-sand-2"
: "flex min-h-dvh flex-col bg-sand-2"
}
>
<TopBar queryKey={data} />
<div className="min-h-0 flex-1 overflow-y-auto">
<div
className={
isDocumentViewer
? "flex min-h-0 flex-1 flex-col overflow-hidden"
: "flex flex-1 flex-col"
}
>
<Outlet />
</div>
<PoweredBy label={t("footer.poweredBy")} />
{isDocumentViewer ? null : <PoweredBy label={t("footer.poweredBy")} />}
</div>
</SubscribeDialogProvider>
</SignInDialogProvider>

View File

@@ -22,8 +22,9 @@ import { TopBarSkeleton } from "#/components/TopBar/TopBarSkeleton";
export function MainLayoutSkeleton() {
return (
<div className="min-h-screen bg-sand-2">
<div className="flex min-h-dvh flex-col bg-sand-2">
<TopBarSkeleton />
<div className="flex flex-1 flex-col" />
</div>
);
}

View File

@@ -18,7 +18,7 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { ArrowRightIcon, ClockIcon, LockSimpleIcon } from "@phosphor-icons/react";
import { ArrowRightIcon, ClockIcon, LockSimpleIcon, SpinnerGapIcon } from "@phosphor-icons/react";
import { Button } from "@probo/ui/src/v2/Button/Button";
import { Link } from "@probo/ui/src/v2/Button/Link";
import { useTranslation } from "react-i18next";
@@ -34,6 +34,30 @@ interface DocumentAccessActionProps {
onGetAccess: () => void;
// Whether the access request is in flight.
isRequesting: boolean;
// When false, render a non-interactive status icon (used as the mobile
// affordance while the row itself handles the hit target).
interactive?: boolean;
}
function StatusIcon({
isAuthorized,
requested,
isRequesting,
}: {
isAuthorized: boolean;
requested: boolean;
isRequesting: boolean;
}) {
if (isAuthorized) {
return <ArrowRightIcon className="size-4 text-sand-12" />;
}
if (requested) {
return <ClockIcon className="size-4 text-sand-11" />;
}
if (isRequesting) {
return <SpinnerGapIcon className="size-4 animate-spin text-sand-12" />;
}
return <LockSimpleIcon className="size-4 text-sand-12" />;
}
// Trailing access control for a document entry: a "View" link to the viewer when
@@ -45,12 +69,31 @@ export function DocumentAccessAction({
viewHref,
onGetAccess,
isRequesting,
interactive = true,
}: DocumentAccessActionProps) {
const { t } = useTranslation("documents");
if (!interactive) {
return (
<span className="flex size-8 items-center justify-center" aria-hidden>
<StatusIcon
isAuthorized={isAuthorized}
requested={requested}
isRequesting={isRequesting}
/>
</span>
);
}
if (isAuthorized) {
return (
<Link to={viewHref} variant="ghost" color="neutral" highContrast iconStart={<ArrowRightIcon />}>
<Link
to={viewHref}
variant="ghost"
color="neutral"
highContrast
iconStart={<ArrowRightIcon />}
>
{t("actions.view")}
</Link>
);

View File

@@ -22,6 +22,8 @@ import { ListItem } from "@probo/ui/src/v2/List/ListItem";
import { ListItemContent } from "@probo/ui/src/v2/List/ListItemContent";
import { Text } from "@probo/ui/src/v2/typography/Text";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Link as RouterLink } from "react-router";
import { DocumentAccessAction } from "./DocumentAccessAction";
@@ -43,8 +45,8 @@ interface DocumentEntryProps {
}
// Presentational row shared by the document / file / report list items: a title
// with accent metadata and the trailing access action. The connection-item
// wrappers own their fragments and supply these values.
// with accent metadata and the trailing access action. On small screens the
// whole row is the hit target (the trailing icon is a status affordance only).
export function DocumentEntry({
title,
meta,
@@ -54,8 +56,21 @@ export function DocumentEntry({
onGetAccess,
isRequesting,
}: DocumentEntryProps) {
const { t } = useTranslation("documents");
const mobileHitLabel = requested
? null
: isAuthorized
? t("actions.view")
: t("actions.getAccess");
return (
<ListItem>
<ListItem
className={[
"relative",
mobileHitLabel != null ? "max-sm:cursor-pointer max-sm:hover:bg-sand-2" : "",
].filter(Boolean).join(" ")}
>
<ListItemContent>
<Text size={2} weight="medium" color="neutral" highContrast className="truncate">
{title}
@@ -64,13 +79,50 @@ export function DocumentEntry({
{meta}
</Text>
</ListItemContent>
<DocumentAccessAction
isAuthorized={isAuthorized}
requested={requested}
viewHref={viewHref}
onGetAccess={onGetAccess}
isRequesting={isRequesting}
/>
{/* Desktop: labeled interactive control. */}
<div className="max-sm:hidden">
<DocumentAccessAction
isAuthorized={isAuthorized}
requested={requested}
viewHref={viewHref}
onGetAccess={onGetAccess}
isRequesting={isRequesting}
/>
</div>
{/* Mobile: status icon only; the row overlay handles activation. */}
<div className="hidden shrink-0 max-sm:block" aria-hidden={mobileHitLabel != null}>
<DocumentAccessAction
isAuthorized={isAuthorized}
requested={requested}
viewHref={viewHref}
onGetAccess={onGetAccess}
isRequesting={isRequesting}
interactive={false}
/>
</div>
{/* Sit above the row content so title / icon areas are part of the hit target. */}
{mobileHitLabel != null && (
isAuthorized
? (
<RouterLink
to={viewHref}
className="absolute inset-0 z-10 hidden max-sm:block"
aria-label={mobileHitLabel}
/>
)
: (
<button
type="button"
className="absolute inset-0 z-10 hidden max-sm:block"
aria-label={mobileHitLabel}
disabled={isRequesting}
onClick={onGetAccess}
/>
)
)}
</ListItem>
);
}

View File

@@ -163,8 +163,14 @@ export function DocumentViewer({ title, dataUri, downloadName }: DocumentViewerP
)}
</div>
<div className={slots.actions()}>
<Button variant="ghost" color="neutral" iconStart={<ShareNetworkIcon />} onClick={handleShare}>
{t("viewer.share")}
<Button
variant="ghost"
color="neutral"
iconStart={<ShareNetworkIcon />}
onClick={handleShare}
aria-label={t("viewer.share")}
>
<span className={slots.actionLabel()}>{t("viewer.share")}</span>
</Button>
<Separator orientation="vertical" className={slots.separator()} />
<Button
@@ -173,8 +179,9 @@ export function DocumentViewer({ title, dataUri, downloadName }: DocumentViewerP
iconStart={<DownloadSimpleIcon />}
disabled={dataUri == null}
onClick={handleDownload}
aria-label={t("viewer.download")}
>
{t("viewer.download")}
<span className={slots.actionLabel()}>{t("viewer.download")}</span>
</Button>
</div>
</div>

View File

@@ -35,7 +35,7 @@ export function DocumentsToolbar() {
return (
<Tabs value={tab} onValueChange={value => setTab(value as DocumentTab)}>
<TabsList>
<TabsList className="max-w-full overflow-x-auto">
{DOCUMENT_TABS.map(value => (
<TabsTab key={value} value={value}>
{t(`tabs.${value}`)}

View File

@@ -28,7 +28,7 @@ import { times } from "@probo/helpers";
// eslint-disable-next-line import-x/default
import workerSrc from "pdfjs-dist/build/pdf.worker.min.mjs?url";
import type { ComponentRef, Ref } from "react";
import { useImperativeHandle, useRef, useState } from "react";
import { useEffect, useImperativeHandle, useRef, useState } from "react";
import { Document, Page, pdfjs } from "react-pdf";
import { pdfPreview } from "./variants";
@@ -37,6 +37,9 @@ import { pdfPreview } from "./variants";
// it from a CDN, so the viewer works under a strict trust-center CSP.
pdfjs.GlobalWorkerOptions.workerSrc = workerSrc;
// Horizontal inset so pages don't kiss the viewport edge on phones.
const PAGE_GUTTER_PX = 32;
export interface PdfPreviewHandle {
scrollToPage: (page: number) => void;
}
@@ -44,7 +47,7 @@ export interface PdfPreviewHandle {
interface PdfPreviewProps {
// Base64 data URI of the PDF to render.
file: string;
// Zoom factor applied to every page.
// Zoom factor applied on top of fit-to-width sizing.
scale: number;
// Handle exposing imperative page navigation to the toolbar.
ref?: Ref<PdfPreviewHandle>;
@@ -54,11 +57,12 @@ interface PdfPreviewProps {
onVisiblePageChange: (page: number) => void;
}
// Scrollable react-pdf renderer, controlled by the viewer toolbar: it takes the
// zoom `scale`, reports the page count and the visible page, and exposes an
// imperative `scrollToPage` for the page-navigation buttons.
// Scrollable react-pdf renderer, controlled by the viewer toolbar: pages fit
// the viewport width by default, then the zoom `scale` multiplies that width.
// Reports the page count and the visible page, and exposes `scrollToPage`.
export function PdfPreview({ file, scale, ref, onNumPages, onVisiblePageChange }: PdfPreviewProps) {
const [numPages, setNumPages] = useState(0);
const [pageWidth, setPageWidth] = useState<number | null>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const documentRef = useRef<ComponentRef<typeof Document>>(null);
@@ -69,6 +73,22 @@ export function PdfPreview({ file, scale, ref, onNumPages, onVisiblePageChange }
},
}), []);
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper) {
return;
}
const updateWidth = () => {
setPageWidth(Math.max(wrapper.clientWidth - PAGE_GUTTER_PX, 1));
};
updateWidth();
const observer = new ResizeObserver(updateWidth);
observer.observe(wrapper);
return () => observer.disconnect();
}, []);
const resolveVisiblePage = () => {
const wrapper = wrapperRef.current;
const pages = documentRef.current?.pages.current;
@@ -104,9 +124,17 @@ export function PdfPreview({ file, scale, ref, onNumPages, onVisiblePageChange }
onVisiblePageChange(1);
}}
>
{times(numPages, index => (
<Page key={index} pageNumber={index + 1} scale={scale} className={slots.page()} />
))}
{pageWidth != null
? times(numPages, index => (
<Page
key={index}
pageNumber={index + 1}
width={pageWidth}
scale={scale}
className={slots.page()}
/>
))
: null}
</Document>
</div>
);

View File

@@ -45,17 +45,20 @@ export const pdfPreview = tv({
// scrollable grey stage for the PDF / image / download-fallback body.
export const documentViewer = tv({
slots: {
root: "flex h-full flex-col",
root: "flex h-full min-h-0 flex-1 flex-col",
header: "flex w-full flex-col gap-3",
back: "-ml-2 self-start",
toolbar: "flex min-h-16 items-center justify-between gap-4",
toolbarStart: "flex items-center gap-2",
// Stay on one row so page/zoom controls and share/download share a baseline;
// icon-only action labels on max-sm keep this viable on phones.
toolbar: "flex min-h-16 flex-wrap items-center justify-between gap-x-4 gap-y-2",
toolbarStart: "flex min-w-0 flex-wrap items-center gap-2",
controls: "flex items-center gap-1",
actions: "flex items-center gap-2",
separator: "h-6",
actions: "flex shrink-0 items-center gap-2",
actionLabel: "max-sm:hidden",
separator: "h-6 max-sm:hidden",
body: "min-h-0 flex-1",
stage: "grid h-full place-items-center bg-sand-3",
imageStage: "grid h-full place-items-center overflow-auto bg-sand-3 p-8",
imageStage: "grid h-full place-items-center overflow-auto bg-sand-3 p-8 max-md:p-4",
image: "max-h-full max-w-full object-contain shadow-3",
spinner: "size-6 animate-spin text-sand-a10",
},

View File

@@ -24,7 +24,7 @@ import { tv } from "tailwind-variants/lite";
// `busy` variant dims the current results while a filtered slice refetches.
export const documentsLayout = tv({
slots: {
page: "flex w-full flex-col items-center px-8 py-8",
page: "flex w-full flex-col items-center px-8 py-8 max-md:px-4",
results: "flex w-full max-w-5xl flex-col gap-8 transition-opacity duration-150",
},
variants: {

View File

@@ -27,10 +27,10 @@ export const ndaPage = tv({
root: "flex h-dvh flex-col",
header: "flex w-full flex-col gap-3",
text: "flex flex-col gap-1",
toolbar: "flex min-h-16 items-center justify-between gap-4",
toolbarStart: "flex items-center gap-2",
toolbar: "flex min-h-16 flex-wrap items-center justify-between gap-x-4 gap-y-2",
toolbarStart: "flex min-w-0 flex-wrap items-center gap-2",
controls: "flex items-center gap-1",
separator: "h-6",
separator: "h-6 max-sm:hidden",
consent: "max-w-2xl",
actions: "flex shrink-0 items-center gap-2",
body: "min-h-0 flex-1",

View File

@@ -61,25 +61,27 @@ export function RightsRequestListItem({ rightsRequestKey }: RightsRequestListIte
const { icon, subline, trailing } = rightsRequestList();
return (
<ListItem>
<span className={icon()}>
{getRightsRequestTypeIcon(request.requestType)}
</span>
<ListItemContent>
<Text size={2} weight="medium" color="neutral" highContrast className="truncate">
{t(`types.${request.requestType}`)}
</Text>
<div className={subline()}>
<Text size={1} color="gold">
{reference}
<ListItem className="max-sm:flex-col max-sm:items-stretch max-sm:gap-2">
<div className="flex min-w-0 flex-1 items-center gap-4">
<span className={icon()}>
{getRightsRequestTypeIcon(request.requestType)}
</span>
<ListItemContent>
<Text size={2} weight="medium" color="neutral" highContrast className="truncate">
{t(`types.${request.requestType}`)}
</Text>
{request.actionTaken != null && request.actionTaken !== "" && (
<Text size={1} color="faint" className="truncate">
{`· ${request.actionTaken}`}
<div className={subline()}>
<Text size={1} color="gold">
{reference}
</Text>
)}
</div>
</ListItemContent>
{request.actionTaken != null && request.actionTaken !== "" && (
<Text size={1} color="faint" className="truncate">
{`· ${request.actionTaken}`}
</Text>
)}
</div>
</ListItemContent>
</div>
<div className={trailing()}>
<Text size={1} color="faint">
{formatRelativeTime(request.createdAt, i18n.language)}

View File

@@ -26,7 +26,7 @@ export const rightsRequestList = tv({
slots: {
icon: "flex size-9 shrink-0 items-center justify-center rounded-3 bg-sand-3 text-sand-a11 [&_svg]:size-4",
subline: "flex min-w-0 items-center gap-1.5",
trailing: "flex shrink-0 items-center gap-3",
trailing: "flex shrink-0 items-center gap-3 max-sm:self-start",
},
});

View File

@@ -23,7 +23,7 @@ import { tv } from "tailwind-variants/lite";
// Data requests page shell: a centered content column below the header band.
export const requestsLayout = tv({
slots: {
page: "flex w-full flex-col items-center px-8 py-8",
page: "flex w-full flex-col items-center px-8 py-8 max-md:px-4",
results: "flex w-full max-w-5xl flex-col gap-6",
loadMore: "flex justify-center",
},

View File

@@ -107,7 +107,7 @@ export function SubprocessorsPage({ queryRef }: SubprocessorsPageProps) {
<PageHeader title={t("title")} count={subprocessors.totalCount} flushBottomSpace>
<SubprocessorsToolbar queryKey={root} />
</PageHeader>
<div className="flex w-full flex-col items-center px-8 py-8">
<div className="flex w-full flex-col items-center px-8 py-8 max-md:px-4">
<div
aria-busy={isRefetching}
className={`flex w-full max-w-5xl flex-col gap-8 transition-opacity duration-150 ${isRefetching ? "opacity-60" : ""}`}

View File

@@ -33,17 +33,17 @@ export function SubprocessorsPageSkeleton() {
<HeaderBand flushBottomSpace>
<div className="flex w-full flex-col gap-2">
<HeadingSkeleton size={7} className="w-64" />
<div className="flex min-h-16 items-center gap-3">
<div className="flex min-h-16 flex-wrap items-center gap-3 max-sm:pb-8">
<SelectSkeleton />
<SelectSkeleton />
<TextFieldSkeleton />
</div>
</div>
</HeaderBand>
<div className="flex w-full flex-col items-center px-8 py-8">
<div className="flex w-full flex-col items-center px-8 py-8 max-md:px-4">
<div className="flex w-full max-w-5xl flex-col gap-4">
<TextSkeleton size={3} className="w-48" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid grid-cols-3 gap-4 max-lg:grid-cols-2 max-sm:grid-cols-1">
{CARD_PLACEHOLDERS.map(placeholder => (
<div key={placeholder} className="h-56 animate-pulse rounded-5 bg-sand-3" />
))}

View File

@@ -51,7 +51,7 @@ export function SubprocessorCategorySection({ category, subprocessors }: Subproc
{t(`categories.${category}.description`)}
</Text>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid grid-cols-3 gap-4 max-lg:grid-cols-2 max-sm:grid-cols-1">
{subprocessors.map(subprocessor => (
<SubprocessorListItem key={subprocessor.id} subprocessorKey={subprocessor} />
))}

View File

@@ -69,8 +69,11 @@ export function SubprocessorsToolbar({ queryKey }: SubprocessorsToolbarProps) {
}, [subprocessorCountries, countryLabel]);
return (
<div className="flex min-h-16 flex-wrap items-center gap-3">
<div className="w-40">
// flushBottomSpace drops the band's bottom padding so the desktop toolbar
// sits on the header edge; restore that padding on small screens where the
// controls stack into a column.
<div className="flex min-h-16 flex-wrap items-center gap-3 max-sm:pb-8">
<div className="w-40 max-sm:w-full">
<Select value={category || null} onValueChange={value => setCategory(value ?? "")}>
<SelectTrigger placeholder={t("filters.allCategories")}>
{(value: string | null) => (value ? t(`categories.${value}.label`) : t("filters.allCategories"))}
@@ -83,7 +86,7 @@ export function SubprocessorsToolbar({ queryKey }: SubprocessorsToolbarProps) {
</SelectPopup>
</Select>
</div>
<div className="w-40">
<div className="w-40 max-sm:w-full">
<Select value={country || null} onValueChange={value => setCountry(value ?? "")}>
<SelectTrigger placeholder={t("filters.allRegions")}>
{(value: string | null) => (value ? countryLabel(value) : t("filters.allRegions"))}
@@ -96,7 +99,7 @@ export function SubprocessorsToolbar({ queryKey }: SubprocessorsToolbarProps) {
</SelectPopup>
</Select>
</div>
<div className="w-60">
<div className="min-w-60 flex-1 max-sm:w-full max-sm:min-w-0">
<TextField
value={queryInput}
onValueChange={setQueryInput}

View File

@@ -106,7 +106,7 @@ export function UpdatesPage({ queryRef }: UpdatesPageProps) {
return (
<>
<PageHeader title={t("title")} actions={isEmpty ? undefined : <UpdatesSubscribeButton />} />
<div className="flex w-full flex-col items-center px-8 py-8">
<div className="flex w-full flex-col items-center px-8 py-8 max-md:px-4">
<div className="w-full max-w-5xl">
{isEmpty
? <UpdatesEmpty />

View File

@@ -18,26 +18,32 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { ButtonSkeleton } from "@probo/ui/src/v2/Button/ButtonSkeleton";
import { PaginationSkeleton } from "@probo/ui/src/v2/Pagination/PaginationSkeleton";
import { HeadingSkeleton } from "@probo/ui/src/v2/typography/HeadingSkeleton";
import { ComplianceArticleItemSkeleton } from "#/components/ComplianceArticleItem/ComplianceArticleItemSkeleton";
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
import { pageHeader } from "#/components/PageHeader/variants";
import { updatesList } from "./_components/variants";
import { UPDATES_PAGE_SIZE } from "./_lib/constants";
export function UpdatesPageSkeleton() {
const { card, rows } = updatesList();
const header = pageHeader();
return (
<>
<HeaderBand>
<div className="flex w-full flex-col gap-2">
<HeadingSkeleton size={7} className="w-40" />
<div className={header.content()}>
<div className={header.titleRow()}>
<HeadingSkeleton size={7} className="w-40" />
<ButtonSkeleton size={2} className="max-sm:w-full" />
</div>
</div>
</HeaderBand>
<div className="flex w-full flex-col items-center px-8 py-8">
<div className="flex w-full flex-col items-center px-8 py-8 max-md:px-4">
<div className="flex w-full max-w-5xl flex-col gap-8">
<div className={card()} aria-hidden>
<div className={rows()}>

View File

@@ -44,8 +44,8 @@ export const updatesList = tv({
// Slots are shared by the detail page and its skeleton.
export const updateArticle = tv({
slots: {
toolbar: "flex w-full items-center justify-between gap-4",
content: "flex w-full flex-col items-center px-8 py-8",
toolbar: "flex w-full items-center justify-between gap-4 max-sm:flex-col max-sm:items-stretch",
content: "flex w-full flex-col items-center px-8 py-8 max-md:px-4",
article: "flex w-full max-w-2xl flex-col gap-4",
meta: "flex items-center gap-1.5",
metaIcon: "size-4 text-gold-9",

View File

@@ -43,7 +43,7 @@ export const dialog = tv({
title: "text-4 font-medium text-sand-12",
description: "text-2 text-sand-11",
body: "px-6",
footer: "flex items-center justify-end gap-3 px-6",
footer: "flex flex-wrap items-center justify-end gap-3 px-6 max-sm:flex-col-reverse max-sm:items-stretch",
},
});

View File

@@ -25,7 +25,7 @@ export const errorState = tv({
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",
actions: "flex flex-wrap items-center justify-center gap-2",
},
variants: {
// Standalone fills the viewport; in-shell sits inside the app chrome.