From 4d8d26aad2e7a7b9aadf4e20b5d653cfdf71b6c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Fri, 31 Jul 2026 10:06:07 +0200 Subject: [PATCH] Split Link/Anchor from button navigators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename button-styled Link/Anchor to ButtonLink/ ButtonAnchor and add underlined text Link/Anchor so names match look and element. Hero meta uses plain Anchors for contact and custom links. Signed-off-by: Émile Ré --- .../Hero/CompliancePortalContactInfo.tsx | 41 ++++--- .../Hero/CompliancePortalCustomLinks.tsx | 108 ++++++++++++++++++ .../Hero/CompliancePortalHeroMeta.tsx | 76 ++++++++++++ .../src/components/Hero/Hero.tsx | 4 +- .../src/components/Hero/HeroSkeleton.tsx | 14 ++- .../src/components/Hero/variants.ts | 15 +-- .../RecentUpdates/RecentUpdatesSection.tsx | 6 +- .../src/components/TopBar/TopBar.tsx | 6 +- .../src/components/TopBar/TopBarMobileNav.tsx | 6 +- .../UnsignedNDABanner/UnsignedNDABanner.tsx | 6 +- .../src/components/errors/GlobalError.tsx | 6 +- apps/compliance-portal/src/pages/HomePage.tsx | 6 +- .../src/pages/NotFoundPage.tsx | 6 +- .../_components/DocumentAccessAction.tsx | 6 +- .../documents/_components/DocumentViewer.tsx | 6 +- .../src/pages/nda/NDAPage.tsx | 6 +- .../src/pages/updates/UpdateDetailPage.tsx | 6 +- contrib/claude/ui.md | 37 ++++-- packages/ui/src/v2/Button/Button.tsx | 5 +- ...r.stories.tsx => ButtonAnchor.stories.tsx} | 22 ++-- packages/ui/src/v2/Button/ButtonAnchor.tsx | 53 +++++++++ .../ui/src/v2/Button/ButtonLink.stories.tsx | 72 ++++++++++++ packages/ui/src/v2/Button/ButtonLink.tsx | 54 +++++++++ packages/ui/src/v2/Button/variants.ts | 8 +- packages/ui/src/v2/Link/Anchor.stories.tsx | 74 ++++++++++++ .../ui/src/v2/{Button => Link}/Anchor.tsx | 12 +- .../src/v2/{Button => Link}/Link.stories.tsx | 40 ++++--- packages/ui/src/v2/{Button => Link}/Link.tsx | 13 +-- packages/ui/src/v2/Link/variants.ts | 78 +++++++++++++ 29 files changed, 668 insertions(+), 124 deletions(-) create mode 100644 apps/compliance-portal/src/components/Hero/CompliancePortalCustomLinks.tsx create mode 100644 apps/compliance-portal/src/components/Hero/CompliancePortalHeroMeta.tsx rename packages/ui/src/v2/Button/{Anchor.stories.tsx => ButtonAnchor.stories.tsx} (81%) create mode 100644 packages/ui/src/v2/Button/ButtonAnchor.tsx create mode 100644 packages/ui/src/v2/Button/ButtonLink.stories.tsx create mode 100644 packages/ui/src/v2/Button/ButtonLink.tsx create mode 100644 packages/ui/src/v2/Link/Anchor.stories.tsx rename packages/ui/src/v2/{Button => Link}/Anchor.tsx (81%) rename packages/ui/src/v2/{Button => Link}/Link.stories.tsx (75%) rename packages/ui/src/v2/{Button => Link}/Link.tsx (79%) create mode 100644 packages/ui/src/v2/Link/variants.ts diff --git a/apps/compliance-portal/src/components/Hero/CompliancePortalContactInfo.tsx b/apps/compliance-portal/src/components/Hero/CompliancePortalContactInfo.tsx index 8965d1f81..4ef1e5a91 100644 --- a/apps/compliance-portal/src/components/Hero/CompliancePortalContactInfo.tsx +++ b/apps/compliance-portal/src/components/Hero/CompliancePortalContactInfo.tsx @@ -19,6 +19,7 @@ // SOFTWARE. import { EnvelopeIcon, GlobeSimpleIcon, MapPinSimpleIcon } from "@phosphor-icons/react"; +import { Anchor } from "@probo/ui/src/v2/Link/Anchor"; import { Text } from "@probo/ui/src/v2/typography/Text"; import { graphql, useFragment } from "react-relay"; @@ -39,8 +40,8 @@ interface CompliancePortalContactInfoProps { compliancePortalKey: CompliancePortalContactInfo_compliancePortal$key; } -// Compliance portal contact details (website, email, HQ) rendered as an icon + -// label row. Owns its fragment so it can be reused wherever the portal is in scope. +// Compliance portal contact details (website, email, HQ) rendered as icon + +// label items for the shared hero meta row. The parent band owns the divider. export function CompliancePortalContactInfo({ compliancePortalKey }: CompliancePortalContactInfoProps) { const compliancePortal = useFragment(compliancePortalContactInfoFragment, compliancePortalKey); @@ -48,35 +49,39 @@ export function CompliancePortalContactInfo({ compliancePortalKey }: ComplianceP const hasEmail = compliancePortal.email != null && compliancePortal.email !== ""; const hasAddress = compliancePortal.headquarterAddress != null && compliancePortal.headquarterAddress !== ""; - // Nothing to show — render no row (and therefore no divider) at all. if (!hasWebsite && !hasEmail && !hasAddress) { return null; } - const { root, item, link } = organizationContactInfo(); + const { item, link } = organizationContactInfo(); return ( -
+ <> {hasWebsite && ( - } > - - - {hostnameOf(compliancePortal.websiteUrl)} - - + {hostnameOf(compliancePortal.websiteUrl)} + )} {hasEmail && ( - - - - {compliancePortal.email} - - + } + > + {compliancePortal.email} + )} {hasAddress && (
@@ -86,6 +91,6 @@ export function CompliancePortalContactInfo({ compliancePortalKey }: ComplianceP
)} -
+ ); } diff --git a/apps/compliance-portal/src/components/Hero/CompliancePortalCustomLinks.tsx b/apps/compliance-portal/src/components/Hero/CompliancePortalCustomLinks.tsx new file mode 100644 index 000000000..d9865d240 --- /dev/null +++ b/apps/compliance-portal/src/components/Hero/CompliancePortalCustomLinks.tsx @@ -0,0 +1,108 @@ +// 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 { + FacebookLogoIcon, + GlobeSimpleIcon, + type Icon, + LinkedinLogoIcon, + XLogoIcon, +} from "@phosphor-icons/react"; +import { detectSocialName } from "@probo/helpers"; +import { Anchor } from "@probo/ui/src/v2/Link/Anchor"; +import { graphql, useFragment } from "react-relay"; + +import { externalHref } from "#/lib/url/hostname"; + +import type { CompliancePortalCustomLinks_compliancePortal$key } from "./__generated__/CompliancePortalCustomLinks_compliancePortal.graphql"; +import { organizationContactInfo } from "./variants"; + +const compliancePortalCustomLinksFragment = graphql` + fragment CompliancePortalCustomLinks_compliancePortal on CompliancePortal { + customLinks(first: 20) { + edges { + node { + id + name + url + } + } + } + } +`; + +interface CompliancePortalCustomLinksProps { + compliancePortalKey: CompliancePortalCustomLinks_compliancePortal$key; +} + +function iconForUrl(url: string): Icon { + switch (detectSocialName(url)) { + case "LinkedIn": + return LinkedinLogoIcon; + case "X": + return XLogoIcon; + case "Facebook": + return FacebookLogoIcon; + default: + return GlobeSimpleIcon; + } +} + +// Organization custom links (social / external URLs) as icon + label items, +// appended after contact details in the shared hero meta row. +export function CompliancePortalCustomLinks({ + compliancePortalKey, +}: CompliancePortalCustomLinksProps) { + const compliancePortal = useFragment( + compliancePortalCustomLinksFragment, + compliancePortalKey, + ); + const links = compliancePortal.customLinks.edges.map(edge => edge.node); + + if (links.length === 0) { + return null; + } + + const { link } = organizationContactInfo(); + + return ( + <> + {links.map((customLink) => { + const Icon = iconForUrl(customLink.url); + + return ( + } + > + {customLink.name} + + ); + })} + + ); +} diff --git a/apps/compliance-portal/src/components/Hero/CompliancePortalHeroMeta.tsx b/apps/compliance-portal/src/components/Hero/CompliancePortalHeroMeta.tsx new file mode 100644 index 000000000..fc36ca744 --- /dev/null +++ b/apps/compliance-portal/src/components/Hero/CompliancePortalHeroMeta.tsx @@ -0,0 +1,76 @@ +// 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 { graphql, useFragment } from "react-relay"; + +import type { CompliancePortalHeroMeta_compliancePortal$key } from "./__generated__/CompliancePortalHeroMeta_compliancePortal.graphql"; +import { CompliancePortalContactInfo } from "./CompliancePortalContactInfo"; +import { CompliancePortalCustomLinks } from "./CompliancePortalCustomLinks"; +import { organizationContactInfo } from "./variants"; + +const compliancePortalHeroMetaFragment = graphql` + fragment CompliancePortalHeroMeta_compliancePortal on CompliancePortal { + websiteUrl + email + headquarterAddress + customLinks(first: 20) { + edges { + __typename + } + } + ...CompliancePortalContactInfo_compliancePortal + ...CompliancePortalCustomLinks_compliancePortal + } +`; + +interface CompliancePortalHeroMetaProps { + compliancePortalKey: CompliancePortalHeroMeta_compliancePortal$key; +} + +// Shared hero bottom band: contact details and custom links in one row with a +// single top divider. Hidden entirely when both are empty. +export function CompliancePortalHeroMeta({ + compliancePortalKey, +}: CompliancePortalHeroMetaProps) { + const compliancePortal = useFragment( + compliancePortalHeroMetaFragment, + compliancePortalKey, + ); + + const hasWebsite = compliancePortal.websiteUrl != null && compliancePortal.websiteUrl !== ""; + const hasEmail = compliancePortal.email != null && compliancePortal.email !== ""; + const hasAddress + = compliancePortal.headquarterAddress != null && compliancePortal.headquarterAddress !== ""; + const hasContact = hasWebsite || hasEmail || hasAddress; + const hasCustomLinks = compliancePortal.customLinks.edges.length > 0; + + if (!hasContact && !hasCustomLinks) { + return null; + } + + const { root } = organizationContactInfo(); + + return ( +
+ + +
+ ); +} diff --git a/apps/compliance-portal/src/components/Hero/Hero.tsx b/apps/compliance-portal/src/components/Hero/Hero.tsx index 6e120e3d8..4cf9f12e9 100644 --- a/apps/compliance-portal/src/components/Hero/Hero.tsx +++ b/apps/compliance-portal/src/components/Hero/Hero.tsx @@ -34,8 +34,8 @@ export interface HeroProps { // Landing hero (home): a size-8 title (+ optional description) in the shared // white band, plus an optional bottom slot for page-specific content (the org -// contact row). The slot content owns its own divider/spacing so it disappears -// cleanly when empty. +// meta row: contact + custom links). The slot content owns its own +// divider/spacing so it disappears cleanly when empty. export function Hero({ title, description, children }: HeroProps) { const { content, section } = hero(); diff --git a/apps/compliance-portal/src/components/Hero/HeroSkeleton.tsx b/apps/compliance-portal/src/components/Hero/HeroSkeleton.tsx index 9ce6d0a7c..610c72de1 100644 --- a/apps/compliance-portal/src/components/Hero/HeroSkeleton.tsx +++ b/apps/compliance-portal/src/components/Hero/HeroSkeleton.tsx @@ -25,12 +25,14 @@ import { HeaderBand } from "#/components/HeaderBand/HeaderBand"; import { hero, organizationContactInfo } from "./variants"; -// Width per contact item, roughly sized to its typical content (hostname / -// email / address). See .cursor/rules/skeleton-width-sync.mdc. -const CONTACT_ITEMS = [ +// Width per meta item, roughly sized to its typical content (hostname / +// email / address / custom link). See .cursor/rules/skeleton-width-sync.mdc. +const META_ITEMS = [ { key: "website", width: "w-28" }, { key: "email", width: "w-40" }, { key: "location", width: "w-36" }, + { key: "customLink1", width: "w-24" }, + { key: "customLink2", width: "w-16" }, ] as const; // Loading placeholder paired with Hero: reuses the same layout slots with @@ -47,9 +49,9 @@ export function HeroSkeleton() {
- {CONTACT_ITEMS.map(contact => ( -
- + {META_ITEMS.map(meta => ( +
+
))}
diff --git a/apps/compliance-portal/src/components/Hero/variants.ts b/apps/compliance-portal/src/components/Hero/variants.ts index ecbd033ea..e9c4614ff 100644 --- a/apps/compliance-portal/src/components/Hero/variants.ts +++ b/apps/compliance-portal/src/components/Hero/variants.ts @@ -21,8 +21,8 @@ import { tv } from "tailwind-variants/lite"; // Landing hero content rendered inside the shared HeaderBand: a centered -// title/description section above an optional bottom slot (the contact row). -// Slots are shared by the live Hero and its skeleton. +// title/description section above an optional bottom slot (contact + custom +// links). Slots are shared by the live Hero and its skeleton. export const hero = tv({ slots: { content: "flex w-full flex-col gap-10", @@ -30,14 +30,15 @@ export const hero = tv({ }, }); -// Organization contact block: a horizontal row of icon + label items, with a -// top divider so it reads as the hero's bottom section. Self-contained so the -// divider only appears when there is contact info to show. +// Hero meta band: contact details and custom links as one icon + label row, +// with a single top divider. Link chrome comes from v2 Anchor; these slots +// only own the band layout and non-link items (HQ address). export const organizationContactInfo = tv({ slots: { // Only a top divider gap; the band's py-8 provides the bottom spacing. 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", + item: "flex min-w-0 items-center gap-2 text-sand-11 [&_svg]:size-4 [&_svg]:shrink-0 *:min-w-0 [&>*:not(svg)]:wrap-break-word", + // Layout extras on top of Anchor (underline/color/size live on Anchor). + link: "min-w-0 max-w-full [&>:not(svg)]:break-all", }, }); diff --git a/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSection.tsx b/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSection.tsx index ae6c359e3..5e8767912 100644 --- a/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSection.tsx +++ b/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSection.tsx @@ -18,7 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import { Link } from "@probo/ui/src/v2/Button/Link"; +import { ButtonLink } from "@probo/ui/src/v2/Button/ButtonLink"; import { ErrorBoundary } from "@probo/ui/src/v2/ErrorBoundary/ErrorBoundary"; import { useTranslation } from "react-i18next"; import { graphql, useFragment } from "react-relay"; @@ -84,9 +84,9 @@ function RecentUpdatesSectionContent({ compliancePortalKey }: RecentUpdatesSecti + {t("home.recentUpdates.viewAll")} - + )} >
diff --git a/apps/compliance-portal/src/components/TopBar/TopBar.tsx b/apps/compliance-portal/src/components/TopBar/TopBar.tsx index af6fa0938..584e8a609 100644 --- a/apps/compliance-portal/src/components/TopBar/TopBar.tsx +++ b/apps/compliance-portal/src/components/TopBar/TopBar.tsx @@ -21,7 +21,7 @@ import { LockSimpleIcon } from "@phosphor-icons/react"; import { Avatar } from "@probo/ui/src/v2/Avatar/Avatar"; import { Button } from "@probo/ui/src/v2/Button/Button"; -import { Link } from "@probo/ui/src/v2/Button/Link"; +import { ButtonLink } from "@probo/ui/src/v2/Button/ButtonLink"; import { Text } from "@probo/ui/src/v2/typography/Text"; import { useTranslation } from "react-i18next"; import { graphql, useFragment } from "react-relay"; @@ -103,7 +103,7 @@ export function TopBar({ queryKey }: TopBarProps) { {TOP_BAR_NAV_ITEMS.map((item) => { const to = localizedPath(item.to); return ( - {t(item.labelKey)} - + ); })} {data.viewer == null diff --git a/apps/compliance-portal/src/components/TopBar/TopBarMobileNav.tsx b/apps/compliance-portal/src/components/TopBar/TopBarMobileNav.tsx index 44ea3f653..c6d0f9196 100644 --- a/apps/compliance-portal/src/components/TopBar/TopBarMobileNav.tsx +++ b/apps/compliance-portal/src/components/TopBar/TopBarMobileNav.tsx @@ -29,7 +29,7 @@ import { XIcon, } from "@phosphor-icons/react"; import { Button } from "@probo/ui/src/v2/Button/Button"; -import { Link } from "@probo/ui/src/v2/Button/Link"; +import { ButtonLink } from "@probo/ui/src/v2/Button/ButtonLink"; import { Drawer } from "@probo/ui/src/v2/Drawer/Drawer"; import { DrawerBody } from "@probo/ui/src/v2/Drawer/DrawerBody"; import { DrawerClose } from "@probo/ui/src/v2/Drawer/DrawerClose"; @@ -133,7 +133,7 @@ export function TopBarMobileNav({ identityKey }: TopBarMobileNavProps) { {TOP_BAR_NAV_ITEMS.map((item) => { const to = localizedPath(item.to); return ( - {t(item.labelKey)} - + ); })} diff --git a/apps/compliance-portal/src/components/UnsignedNDABanner/UnsignedNDABanner.tsx b/apps/compliance-portal/src/components/UnsignedNDABanner/UnsignedNDABanner.tsx index 4d846cc9a..e9f57a491 100644 --- a/apps/compliance-portal/src/components/UnsignedNDABanner/UnsignedNDABanner.tsx +++ b/apps/compliance-portal/src/components/UnsignedNDABanner/UnsignedNDABanner.tsx @@ -19,7 +19,7 @@ // SOFTWARE. import { FileTextIcon } from "@phosphor-icons/react"; -import { Link } from "@probo/ui/src/v2/Button/Link"; +import { ButtonLink } from "@probo/ui/src/v2/Button/ButtonLink"; import { Text } from "@probo/ui/src/v2/typography/Text"; import { useState } from "react"; import { useTranslation } from "react-i18next"; @@ -72,14 +72,14 @@ export function UnsignedNDABanner({ compliancePortalKey }: UnsignedNDABannerProp )} actions={( - {t("nda.unsignedBanner.sign")} - + )} dismissLabel={t("nda.unsignedBanner.dismiss")} onDismiss={() => setDismissed(true)} diff --git a/apps/compliance-portal/src/components/errors/GlobalError.tsx b/apps/compliance-portal/src/components/errors/GlobalError.tsx index c67916e2a..430cd4572 100644 --- a/apps/compliance-portal/src/components/errors/GlobalError.tsx +++ b/apps/compliance-portal/src/components/errors/GlobalError.tsx @@ -14,7 +14,7 @@ import { ForbiddenError, InternalServerError, UnAuthenticatedError } from "@probo/relay"; import { Button } from "@probo/ui/src/v2/Button/Button"; -import { Link } from "@probo/ui/src/v2/Button/Link"; +import { ButtonLink } from "@probo/ui/src/v2/Button/ButtonLink"; import { ErrorState } from "@probo/ui/src/v2/ErrorState/ErrorState"; import { useTranslation } from "react-i18next"; @@ -76,9 +76,9 @@ export function GlobalError({ error, onRetry, fullPage = false }: GlobalErrorPro description={t(descriptionKey)} actions={( <> - + {t("errors.actions.backToCompliancePortal")} - + {onRetry && (