diff --git a/apps/compliance-portal/src/_locales/en-US.json b/apps/compliance-portal/src/_locales/en-US.json index 58c13c777..fe7148871 100644 --- a/apps/compliance-portal/src/_locales/en-US.json +++ b/apps/compliance-portal/src/_locales/en-US.json @@ -2,6 +2,9 @@ "topBar": { "tagline": "Compliance Portal", "getAccess": "Get Access", + "openMenu": "Open menu", + "closeMenu": "Close menu", + "menuTitle": "Menu", "nav": { "documents": "Documents", "subprocessors": "Subprocessors", diff --git a/apps/compliance-portal/src/_locales/fr-FR.json b/apps/compliance-portal/src/_locales/fr-FR.json index b376b88c3..65f8a4f26 100644 --- a/apps/compliance-portal/src/_locales/fr-FR.json +++ b/apps/compliance-portal/src/_locales/fr-FR.json @@ -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", diff --git a/apps/compliance-portal/src/components/BackdropCard/variants.ts b/apps/compliance-portal/src/components/BackdropCard/variants.ts index d6a077499..ce548dae0 100644 --- a/apps/compliance-portal/src/components/BackdropCard/variants.ts +++ b/apps/compliance-portal/src/components/BackdropCard/variants.ts @@ -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", }, }); diff --git a/apps/compliance-portal/src/components/ComplianceArticleItem/ComplianceArticleItem.tsx b/apps/compliance-portal/src/components/ComplianceArticleItem/ComplianceArticleItem.tsx index ed0f12557..21931fb66 100644 --- a/apps/compliance-portal/src/components/ComplianceArticleItem/ComplianceArticleItem.tsx +++ b/apps/compliance-portal/src/components/ComplianceArticleItem/ComplianceArticleItem.tsx @@ -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
{icon}
- - {title} - - {eyebrow != null && ( - - {eyebrow} +
+ + {title} + + {eyebrow != null && ( + + {eyebrow} + + )} +
+ {meta != null && ( + + {meta} )}
- {meta != null && ( - - {meta} - - )}
); } diff --git a/apps/compliance-portal/src/components/ComplianceArticleItem/ComplianceArticleItemSkeleton.tsx b/apps/compliance-portal/src/components/ComplianceArticleItem/ComplianceArticleItemSkeleton.tsx index 973da1a47..ae08b0e7c 100644 --- a/apps/compliance-portal/src/components/ComplianceArticleItem/ComplianceArticleItemSkeleton.tsx +++ b/apps/compliance-portal/src/components/ComplianceArticleItem/ComplianceArticleItemSkeleton.tsx @@ -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() {
- +
+ +
+
-
); } diff --git a/apps/compliance-portal/src/components/ComplianceArticleItem/variants.ts b/apps/compliance-portal/src/components/ComplianceArticleItem/variants.ts index d1c9ede43..e4d188859 100644 --- a/apps/compliance-portal/src/components/ComplianceArticleItem/variants.ts +++ b/apps/compliance-portal/src/components/ComplianceArticleItem/variants.ts @@ -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", }, diff --git a/apps/compliance-portal/src/components/ComplianceFrameworks/ComplianceFrameworksSection.tsx b/apps/compliance-portal/src/components/ComplianceFrameworks/ComplianceFrameworksSection.tsx index 0e1310a47..c15d08f5d 100644 --- a/apps/compliance-portal/src/components/ComplianceFrameworks/ComplianceFrameworksSection.tsx +++ b/apps/compliance-portal/src/components/ComplianceFrameworks/ComplianceFrameworksSection.tsx @@ -80,7 +80,7 @@ function ComplianceFrameworksSectionContent({ trustCenterKey }: ComplianceFramew return ( -
+
{frameworks.map(framework => ( ))} diff --git a/apps/compliance-portal/src/components/ComplianceFrameworks/ComplianceFrameworksSectionSkeleton.tsx b/apps/compliance-portal/src/components/ComplianceFrameworks/ComplianceFrameworksSectionSkeleton.tsx index f0930aeb2..2c6d95c9b 100644 --- a/apps/compliance-portal/src/components/ComplianceFrameworks/ComplianceFrameworksSectionSkeleton.tsx +++ b/apps/compliance-portal/src/components/ComplianceFrameworks/ComplianceFrameworksSectionSkeleton.tsx @@ -31,7 +31,7 @@ export function ComplianceFrameworksSectionSkeleton() {
-
+
{Array.from({ length: 4 }, (_, index) => ( ))} diff --git a/apps/compliance-portal/src/components/HeaderBand/variants.ts b/apps/compliance-portal/src/components/HeaderBand/variants.ts index a72135567..72efa6469 100644 --- a/apps/compliance-portal/src/components/HeaderBand/variants.ts +++ b/apps/compliance-portal/src/components/HeaderBand/variants.ts @@ -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: { diff --git a/apps/compliance-portal/src/components/Hero/variants.ts b/apps/compliance-portal/src/components/Hero/variants.ts index 4865adbb7..ecbd033ea 100644 --- a/apps/compliance-portal/src/components/Hero/variants.ts +++ b/apps/compliance-portal/src/components/Hero/variants.ts @@ -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", }, }); diff --git a/apps/compliance-portal/src/components/PageHeader/variants.ts b/apps/compliance-portal/src/components/PageHeader/variants.ts index bd13ad07a..4153244a2 100644 --- a/apps/compliance-portal/src/components/PageHeader/variants.ts +++ b/apps/compliance-portal/src/components/PageHeader/variants.ts @@ -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", }, diff --git a/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSection.tsx b/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSection.tsx index 8b5b6b701..9bae85fdb 100644 --- a/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSection.tsx +++ b/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSection.tsx @@ -89,7 +89,7 @@ function RecentUpdatesSectionContent({ trustCenterKey }: RecentUpdatesSectionPro >
-
+
{updates.map(update => ( // Rows share fate (one connection query), so a row's field error diff --git a/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSectionSkeleton.tsx b/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSectionSkeleton.tsx index 1d716e749..5d33c5e04 100644 --- a/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSectionSkeleton.tsx +++ b/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSectionSkeleton.tsx @@ -35,7 +35,7 @@ export function RecentUpdatesSectionSkeleton() {
-
+
{Array.from({ length: 5 }, (_, index) => ( diff --git a/apps/compliance-portal/src/components/SecurityCommitments/variants.ts b/apps/compliance-portal/src/components/SecurityCommitments/variants.ts index 9970b3009..63de5b068 100644 --- a/apps/compliance-portal/src/components/SecurityCommitments/variants.ts +++ b/apps/compliance-portal/src/components/SecurityCommitments/variants.ts @@ -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", }, }); diff --git a/apps/compliance-portal/src/components/TopBar/TopBar.tsx b/apps/compliance-portal/src/components/TopBar/TopBar.tsx index 18ce8f00a..6eadf5096 100644 --- a/apps/compliance-portal/src/components/TopBar/TopBar.tsx +++ b/apps/compliance-portal/src/components/TopBar/TopBar.tsx @@ -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()} /> - + {organizationName} - + {t("topBar.tagline")} @@ -99,7 +95,7 @@ export function TopBar({ queryKey }: TopBarProps) {
+ +
); diff --git a/apps/compliance-portal/src/components/TopBar/TopBarMobileNav.tsx b/apps/compliance-portal/src/components/TopBar/TopBarMobileNav.tsx new file mode 100644 index 000000000..8c74783ea --- /dev/null +++ b/apps/compliance-portal/src/components/TopBar/TopBarMobileNav.tsx @@ -0,0 +1,212 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 ( + +
+ + + + )} + /> +
+ + + + + +
+ + {t("topBar.menuTitle")} + + + + + )} + /> +
+ + + +
+ {identity == null + ? ( + + ) + : ( + <> + {displayName + ? ( +
+ + {displayName} + + + {identity.email} + +
+ ) + : null} + + + + )} +
+
+
+
+
+
+ ); +} diff --git a/apps/compliance-portal/src/components/TopBar/TopBarSkeleton.tsx b/apps/compliance-portal/src/components/TopBar/TopBarSkeleton.tsx index 59b991d51..0cbb0269e 100644 --- a/apps/compliance-portal/src/components/TopBar/TopBarSkeleton.tsx +++ b/apps/compliance-portal/src/components/TopBar/TopBarSkeleton.tsx @@ -43,7 +43,8 @@ export function TopBarSkeleton() {
- + +
@@ -54,6 +55,10 @@ export function TopBarSkeleton() { ))} + +
+
+
); diff --git a/apps/compliance-portal/src/components/TopBar/TopBarUserMenu.tsx b/apps/compliance-portal/src/components/TopBar/TopBarUserMenu.tsx index 4defd9528..b53ee59d6 100644 --- a/apps/compliance-portal/src/components/TopBar/TopBarUserMenu.tsx +++ b/apps/compliance-portal/src/components/TopBar/TopBarUserMenu.tsx @@ -76,10 +76,10 @@ export function TopBarUserMenu({ identityKey }: TopBarUserMenuProps) { radius="small" fallback={} /> - + {displayName} - + )} /> diff --git a/apps/compliance-portal/src/components/TopBar/navItems.ts b/apps/compliance-portal/src/components/TopBar/navItems.ts new file mode 100644 index 000000000..171a4691d --- /dev/null +++ b/apps/compliance-portal/src/components/TopBar/navItems.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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; diff --git a/apps/compliance-portal/src/components/TopBar/variants.ts b/apps/compliance-portal/src/components/TopBar/variants.ts index 829853439..45c796216 100644 --- a/apps/compliance-portal/src/components/TopBar/variants.ts +++ b/apps/compliance-portal/src/components/TopBar/variants.ts @@ -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", + }, +}); diff --git a/apps/compliance-portal/src/components/TrustedBy/TrustedBySection.tsx b/apps/compliance-portal/src/components/TrustedBy/TrustedBySection.tsx index 6d718d4c4..ac96d3284 100644 --- a/apps/compliance-portal/src/components/TrustedBy/TrustedBySection.tsx +++ b/apps/compliance-portal/src/components/TrustedBy/TrustedBySection.tsx @@ -78,7 +78,7 @@ function TrustedBySectionContent({ trustCenterKey }: TrustedBySectionProps) { return ( -
+
{references.map(reference => ( ))} diff --git a/apps/compliance-portal/src/components/TrustedBy/TrustedBySectionSkeleton.tsx b/apps/compliance-portal/src/components/TrustedBy/TrustedBySectionSkeleton.tsx index 0b82229a7..c9792b0dc 100644 --- a/apps/compliance-portal/src/components/TrustedBy/TrustedBySectionSkeleton.tsx +++ b/apps/compliance-portal/src/components/TrustedBy/TrustedBySectionSkeleton.tsx @@ -31,7 +31,7 @@ export function TrustedBySectionSkeleton() {
-
+
{Array.from({ length: 6 }, (_, index) => ( ))} diff --git a/apps/compliance-portal/src/pages/HomePage.tsx b/apps/compliance-portal/src/pages/HomePage.tsx index f204d1f08..3041ac56b 100644 --- a/apps/compliance-portal/src/pages/HomePage.tsx +++ b/apps/compliance-portal/src/pages/HomePage.tsx @@ -64,7 +64,7 @@ export function HomePage({ queryRef }: HomePageProps) { > -
+
diff --git a/apps/compliance-portal/src/pages/HomePageSkeleton.tsx b/apps/compliance-portal/src/pages/HomePageSkeleton.tsx index 8ff87ddb1..47d2daa84 100644 --- a/apps/compliance-portal/src/pages/HomePageSkeleton.tsx +++ b/apps/compliance-portal/src/pages/HomePageSkeleton.tsx @@ -28,7 +28,7 @@ export function HomePageSkeleton() { return ( <> -
+
diff --git a/apps/compliance-portal/src/pages/MainLayout.tsx b/apps/compliance-portal/src/pages/MainLayout.tsx index 9ba2cf066..a2cc1aa16 100644 --- a/apps/compliance-portal/src/pages/MainLayout.tsx +++ b/apps/compliance-portal/src/pages/MainLayout.tsx @@ -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, 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. -
+
-
+
- + {isDocumentViewer ? null : }
diff --git a/apps/compliance-portal/src/pages/MainLayoutSkeleton.tsx b/apps/compliance-portal/src/pages/MainLayoutSkeleton.tsx index 18ff9ad99..1e83437f6 100644 --- a/apps/compliance-portal/src/pages/MainLayoutSkeleton.tsx +++ b/apps/compliance-portal/src/pages/MainLayoutSkeleton.tsx @@ -22,8 +22,9 @@ import { TopBarSkeleton } from "#/components/TopBar/TopBarSkeleton"; export function MainLayoutSkeleton() { return ( -
+
+
); } diff --git a/apps/compliance-portal/src/pages/documents/_components/DocumentAccessAction.tsx b/apps/compliance-portal/src/pages/documents/_components/DocumentAccessAction.tsx index d81be9448..7f01a6285 100644 --- a/apps/compliance-portal/src/pages/documents/_components/DocumentAccessAction.tsx +++ b/apps/compliance-portal/src/pages/documents/_components/DocumentAccessAction.tsx @@ -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 ; + } + if (requested) { + return ; + } + if (isRequesting) { + return ; + } + return ; } // 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 ( + + + + ); + } + if (isAuthorized) { return ( - }> + } + > {t("actions.view")} ); diff --git a/apps/compliance-portal/src/pages/documents/_components/DocumentEntry.tsx b/apps/compliance-portal/src/pages/documents/_components/DocumentEntry.tsx index 6bccef884..c392e455e 100644 --- a/apps/compliance-portal/src/pages/documents/_components/DocumentEntry.tsx +++ b/apps/compliance-portal/src/pages/documents/_components/DocumentEntry.tsx @@ -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 ( - + {title} @@ -64,13 +79,50 @@ export function DocumentEntry({ {meta} - + + {/* Desktop: labeled interactive control. */} +
+ +
+ + {/* Mobile: status icon only; the row overlay handles activation. */} +
+ +
+ + {/* Sit above the row content so title / icon areas are part of the hit target. */} + {mobileHitLabel != null && ( + isAuthorized + ? ( + + ) + : ( +
-
diff --git a/apps/compliance-portal/src/pages/documents/_components/DocumentsToolbar.tsx b/apps/compliance-portal/src/pages/documents/_components/DocumentsToolbar.tsx index 15920b2af..227bdc69b 100644 --- a/apps/compliance-portal/src/pages/documents/_components/DocumentsToolbar.tsx +++ b/apps/compliance-portal/src/pages/documents/_components/DocumentsToolbar.tsx @@ -35,7 +35,7 @@ export function DocumentsToolbar() { return ( setTab(value as DocumentTab)}> - + {DOCUMENT_TABS.map(value => ( {t(`tabs.${value}`)} diff --git a/apps/compliance-portal/src/pages/documents/_components/PdfPreview.tsx b/apps/compliance-portal/src/pages/documents/_components/PdfPreview.tsx index 88319e83d..ec7e91c47 100644 --- a/apps/compliance-portal/src/pages/documents/_components/PdfPreview.tsx +++ b/apps/compliance-portal/src/pages/documents/_components/PdfPreview.tsx @@ -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; @@ -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(null); const wrapperRef = useRef(null); const documentRef = useRef>(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 => ( - - ))} + {pageWidth != null + ? times(numPages, index => ( + + )) + : null}
); diff --git a/apps/compliance-portal/src/pages/documents/_components/variants.ts b/apps/compliance-portal/src/pages/documents/_components/variants.ts index 6700cb482..94b55da9a 100644 --- a/apps/compliance-portal/src/pages/documents/_components/variants.ts +++ b/apps/compliance-portal/src/pages/documents/_components/variants.ts @@ -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", }, diff --git a/apps/compliance-portal/src/pages/documents/variants.ts b/apps/compliance-portal/src/pages/documents/variants.ts index 10da65c80..b66aaf220 100644 --- a/apps/compliance-portal/src/pages/documents/variants.ts +++ b/apps/compliance-portal/src/pages/documents/variants.ts @@ -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: { diff --git a/apps/compliance-portal/src/pages/nda/variants.ts b/apps/compliance-portal/src/pages/nda/variants.ts index 110eb6908..4e70804f1 100644 --- a/apps/compliance-portal/src/pages/nda/variants.ts +++ b/apps/compliance-portal/src/pages/nda/variants.ts @@ -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", diff --git a/apps/compliance-portal/src/pages/requests/_components/RightsRequestListItem.tsx b/apps/compliance-portal/src/pages/requests/_components/RightsRequestListItem.tsx index eecfc1499..5c8774c67 100644 --- a/apps/compliance-portal/src/pages/requests/_components/RightsRequestListItem.tsx +++ b/apps/compliance-portal/src/pages/requests/_components/RightsRequestListItem.tsx @@ -61,25 +61,27 @@ export function RightsRequestListItem({ rightsRequestKey }: RightsRequestListIte const { icon, subline, trailing } = rightsRequestList(); return ( - - - {getRightsRequestTypeIcon(request.requestType)} - - - - {t(`types.${request.requestType}`)} - -
- - {reference} + +
+ + {getRightsRequestTypeIcon(request.requestType)} + + + + {t(`types.${request.requestType}`)} - {request.actionTaken != null && request.actionTaken !== "" && ( - - {`· ${request.actionTaken}`} +
+ + {reference} - )} -
-
+ {request.actionTaken != null && request.actionTaken !== "" && ( + + {`· ${request.actionTaken}`} + + )} +
+ +
{formatRelativeTime(request.createdAt, i18n.language)} diff --git a/apps/compliance-portal/src/pages/requests/_components/variants.ts b/apps/compliance-portal/src/pages/requests/_components/variants.ts index 8bed95dd2..b5ea98c82 100644 --- a/apps/compliance-portal/src/pages/requests/_components/variants.ts +++ b/apps/compliance-portal/src/pages/requests/_components/variants.ts @@ -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", }, }); diff --git a/apps/compliance-portal/src/pages/requests/variants.ts b/apps/compliance-portal/src/pages/requests/variants.ts index 005b9f2b5..22ab7dfa1 100644 --- a/apps/compliance-portal/src/pages/requests/variants.ts +++ b/apps/compliance-portal/src/pages/requests/variants.ts @@ -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", }, diff --git a/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPage.tsx b/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPage.tsx index 26b93c351..f698d4a8e 100644 --- a/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPage.tsx +++ b/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPage.tsx @@ -107,7 +107,7 @@ export function SubprocessorsPage({ queryRef }: SubprocessorsPageProps) { -
+
-
+
-
+
-
+
{CARD_PLACEHOLDERS.map(placeholder => (
))} diff --git a/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorCategorySection.tsx b/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorCategorySection.tsx index 0ff6bce77..afe95c1ad 100644 --- a/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorCategorySection.tsx +++ b/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorCategorySection.tsx @@ -51,7 +51,7 @@ export function SubprocessorCategorySection({ category, subprocessors }: Subproc {t(`categories.${category}.description`)}
-
+
{subprocessors.map(subprocessor => ( ))} diff --git a/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorsToolbar.tsx b/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorsToolbar.tsx index 222410afd..57dd15761 100644 --- a/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorsToolbar.tsx +++ b/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorsToolbar.tsx @@ -69,8 +69,11 @@ export function SubprocessorsToolbar({ queryKey }: SubprocessorsToolbarProps) { }, [subprocessorCountries, countryLabel]); return ( -
-
+ // 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. +
+
-
+
-
+
} /> -
+
{isEmpty ? diff --git a/apps/compliance-portal/src/pages/updates/UpdatesPageSkeleton.tsx b/apps/compliance-portal/src/pages/updates/UpdatesPageSkeleton.tsx index 144c1930a..5e83e482c 100644 --- a/apps/compliance-portal/src/pages/updates/UpdatesPageSkeleton.tsx +++ b/apps/compliance-portal/src/pages/updates/UpdatesPageSkeleton.tsx @@ -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 ( <> -
- +
+
+ + +
-
+
diff --git a/apps/compliance-portal/src/pages/updates/_components/variants.ts b/apps/compliance-portal/src/pages/updates/_components/variants.ts index 9482f8a13..eee71d148 100644 --- a/apps/compliance-portal/src/pages/updates/_components/variants.ts +++ b/apps/compliance-portal/src/pages/updates/_components/variants.ts @@ -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", diff --git a/packages/ui/src/v2/Dialog/variants.ts b/packages/ui/src/v2/Dialog/variants.ts index 38abe2fd3..23d64e43b 100644 --- a/packages/ui/src/v2/Dialog/variants.ts +++ b/packages/ui/src/v2/Dialog/variants.ts @@ -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", }, }); diff --git a/packages/ui/src/v2/ErrorState/variants.ts b/packages/ui/src/v2/ErrorState/variants.ts index 4c63010c9..456a7b49e 100644 --- a/packages/ui/src/v2/ErrorState/variants.ts +++ b/packages/ui/src/v2/ErrorState/variants.ts @@ -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.