Add document viewer to the compliance portal

Add a full-page viewer at /documents/:alias that resolves the aliased
node, exports its watermarked bytes, and renders them: PDFs via react-pdf
with page navigation and zoom, images inline, and a download fallback for
other file types. Unauthorized visitors see a locked state.

Wire the documents list "View" action to link into the viewer (fragments
now select alias) and drop the previous open-in-new-tab helpers, since the
viewer owns the export.

Bound MainLayout to the viewport so the top bar and footer stay fixed and
the page area scrolls on its own; the viewer then keeps its toolbar fixed
while the PDF body scrolls, matching the design.

Add the react-pdf dependency with the pdf.js worker bundled via Vite (for
CSP safety) and a headless v2 Separator kit component for the toolbar.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-16 11:14:51 +02:00
parent 99990d6f6b
commit 6054c92899
24 changed files with 929 additions and 129 deletions

View File

@@ -17,9 +17,11 @@
"@probo/routes": "1.0.0",
"@probo/ui": "1.0.0",
"i18next": "^26.3.4",
"pdfjs-dist": "^5.4.296",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-i18next": "^17.0.8",
"react-pdf": "^10.3.0",
"react-relay": "^21.0.1",
"react-router": "^8.1.0",
"relay-runtime": "^21.0.1"

View File

@@ -43,9 +43,12 @@ export function MainLayout({ queryRef }: MainLayoutProps) {
const data = usePreloadedQuery<MainLayoutQuery>(mainLayoutQuery, queryRef);
return (
<div className="flex min-h-screen flex-col bg-sand-2">
// 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.
<div className="flex h-dvh flex-col bg-sand-2">
<TopBar queryKey={data} />
<div className="flex-1">
<div className="min-h-0 flex-1 overflow-y-auto">
<Outlet />
</div>
<PoweredBy label={t("footer.poweredBy")} />

View File

@@ -0,0 +1,90 @@
// 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 type { PreloadedQuery } from "react-relay";
import { graphql, usePreloadedQuery } from "react-relay";
import type { DocumentViewerPageQuery } from "./__generated__/DocumentViewerPageQuery.graphql";
import { DocumentLocked } from "./_components/DocumentLocked";
import { DocumentViewer } from "./_components/DocumentViewer";
import type { DocumentKind } from "./_lib/useDocumentExport";
import { useDocumentExport } from "./_lib/useDocumentExport";
export const documentViewerPageQuery = graphql`
query DocumentViewerPageQuery($alias: String!) {
aliasedNode(alias: $alias) @required(action: THROW) {
__typename
... on Document {
id
title
isUserAuthorized
}
... on TrustCenterFile {
id
name
isUserAuthorized
}
... on AuditReport {
id
fileName
isUserAuthorized
}
}
}
`;
interface DocumentViewerPageProps {
queryRef: PreloadedQuery<DocumentViewerPageQuery>;
}
interface ResolvedNode {
kind: DocumentKind;
id: string;
title: string;
isAuthorized: boolean;
}
function resolveNode(node: DocumentViewerPageQuery["response"]["aliasedNode"]): ResolvedNode {
switch (node.__typename) {
case "Document":
return { kind: "Document", id: node.id, title: node.title, isAuthorized: node.isUserAuthorized };
case "TrustCenterFile":
return { kind: "TrustCenterFile", id: node.id, title: node.name, isAuthorized: node.isUserAuthorized };
case "AuditReport":
return { kind: "AuditReport", id: node.id, title: node.fileName, isAuthorized: node.isUserAuthorized };
default:
throw new Error(`Unexpected aliased node type: ${node.__typename}`);
}
}
// Full-page viewer for a single document/file/report resolved by its alias. It
// exports the (watermarked) bytes and renders them; unauthorized visitors get a
// locked state instead.
export function DocumentViewerPage({ queryRef }: DocumentViewerPageProps) {
const data = usePreloadedQuery<DocumentViewerPageQuery>(documentViewerPageQuery, queryRef);
const node = resolveNode(data.aliasedNode);
const { dataUri } = useDocumentExport(node.kind, node.id, node.isAuthorized);
if (!node.isAuthorized) {
return <DocumentLocked />;
}
return <DocumentViewer title={node.title} dataUri={dataUri} downloadName={node.title} />;
}

View File

@@ -18,25 +18,27 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
// Opens a base64 data URI (returned by the export mutations) in a new browser
// tab. Decodes to a Blob so the object URL carries the right MIME type and the
// browser previews the PDF inline instead of navigating to a huge data: URL.
export function openExportedFile(dataUri: string): void {
const commaIndex = dataUri.indexOf(",");
const base64 = commaIndex === -1 ? dataUri : dataUri.slice(commaIndex + 1);
const mimeMatch = dataUri.match(/^data:([^;]+);/);
const mimeType = mimeMatch?.[1] ?? "application/octet-stream";
import { useEffect } from "react";
import { useQueryLoader } from "react-relay";
import { useParams } from "react-router";
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
import type { DocumentViewerPageQuery } from "./__generated__/DocumentViewerPageQuery.graphql";
import { DocumentViewerPage, documentViewerPageQuery } from "./DocumentViewerPage";
import { DocumentViewerPageSkeleton } from "./DocumentViewerPageSkeleton";
export default function DocumentViewerPageLoader() {
const { alias } = useParams();
const [queryRef, loadQuery] = useQueryLoader<DocumentViewerPageQuery>(documentViewerPageQuery);
useEffect(() => {
if (alias) {
loadQuery({ alias });
}
}, [loadQuery, alias]);
if (!queryRef) {
return <DocumentViewerPageSkeleton />;
}
const blob = new Blob([bytes], { type: mimeType });
const objectUrl = URL.createObjectURL(blob);
window.open(objectUrl, "_blank", "noopener,noreferrer");
// Give the new tab time to claim the URL before releasing it.
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
return <DocumentViewerPage queryRef={queryRef} />;
}

View File

@@ -18,34 +18,35 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { useCallback } from "react";
import type { GraphQLTaggedNode, MutationParameters } from "relay-runtime";
import { ButtonSkeleton } from "@probo/ui/src/v2/Button/ButtonSkeleton";
import { HeadingSkeleton } from "@probo/ui/src/v2/typography/HeadingSkeleton";
import { TextSkeleton } from "@probo/ui/src/v2/typography/TextSkeleton";
import { useMutation } from "#/lib/relay/useMutation";
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
import { openExportedFile } from "./openExportedFile";
import { documentViewer } from "./_components/variants";
// Shared "export then open" behavior for the document/file/report list items:
// commit the resource's export mutation, open the returned base64 payload, and
// let failures surface through the mutation notifier's toast. Each caller
// supplies its typed mutation and a selector for the payload string.
export function useExportAndOpen<T extends MutationParameters>(
mutation: GraphQLTaggedNode,
selectData: (response: T["response"]) => string,
): readonly [(variables: T["variables"]) => void, boolean] {
const [commit, isExporting] = useMutation<T>(mutation);
export function DocumentViewerPageSkeleton() {
const slots = documentViewer();
const open = useCallback(
(variables: T["variables"]) => {
commit({
variables,
onCompleted: response => openExportedFile(selectData(response)),
}).catch(() => {
// The mutation failure is already surfaced through a toast.
});
},
[commit, selectData],
return (
<div className={slots.root()}>
<HeaderBand flushBottomSpace>
<div className={slots.header()}>
<TextSkeleton size={1} className="w-20" />
<HeadingSkeleton size={7} className="w-80" />
<div className={slots.toolbar()}>
<ButtonSkeleton size={2} />
<div className={slots.actions()}>
<ButtonSkeleton size={2} />
<ButtonSkeleton size={2} />
</div>
</div>
</div>
</HeaderBand>
<div className={slots.body()}>
<div className={slots.stage()} />
</div>
</div>
);
return [open, isExporting];
}

View File

@@ -20,10 +20,7 @@
import { graphql, useFragment } from "react-relay";
import { useExportAndOpen } from "../_lib/useExportAndOpen";
import type { AuditReportListItem_audit$key } from "./__generated__/AuditReportListItem_audit.graphql";
import type { AuditReportListItemExportMutation } from "./__generated__/AuditReportListItemExportMutation.graphql";
import { DocumentEntry } from "./DocumentEntry";
const auditReportListItemFragment = graphql`
@@ -33,6 +30,7 @@ const auditReportListItemFragment = graphql`
}
reportFile {
id
alias
fileName
isUserAuthorized
access {
@@ -42,27 +40,15 @@ const auditReportListItemFragment = graphql`
}
`;
const exportReportMutation = graphql`
mutation AuditReportListItemExportMutation($input: ExportReportPDFInput!) {
exportReportPDF(input: $input) {
data
}
}
`;
interface AuditReportListItemProps {
auditKey: AuditReportListItem_audit$key;
}
// A single audit report entry: the framework name, the report file name, and an
// access action that opens the exported report when the viewer is authorized.
// Renders nothing when the audit has no report file.
// access action linking to the viewer when authorized. Renders nothing when the
// audit has no report file.
export function AuditReportListItem({ auditKey }: AuditReportListItemProps) {
const audit = useFragment(auditReportListItemFragment, auditKey);
const [openReport, isExporting] = useExportAndOpen<AuditReportListItemExportMutation>(
exportReportMutation,
response => response.exportReportPDF.data,
);
const report = audit.reportFile;
if (report == null) {
@@ -75,8 +61,7 @@ export function AuditReportListItem({ auditKey }: AuditReportListItemProps) {
meta={report.fileName}
isAuthorized={report.isUserAuthorized}
requested={report.access?.status === "REQUESTED"}
onView={() => openReport({ input: { reportId: report.id } })}
isViewing={isExporting}
viewHref={`/documents/${encodeURIComponent(report.alias ?? report.id)}`}
/>
);
}

View File

@@ -18,8 +18,9 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { ArrowSquareOutIcon, ClockIcon, LockSimpleIcon } from "@phosphor-icons/react";
import { ArrowRightIcon, ClockIcon, LockSimpleIcon } 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";
interface DocumentAccessActionProps {
@@ -27,30 +28,21 @@ interface DocumentAccessActionProps {
isAuthorized: boolean;
// Whether an access request is already pending for this document.
requested: boolean;
// Opens the document; only invoked when authorized.
onView: () => void;
// Whether the export/open is in flight.
isViewing: boolean;
// Route to the document viewer, used when authorized.
viewHref: string;
}
// Trailing access control for a document entry: "View" when authorized, a
// pending label when access was requested, otherwise a (currently inert) "Get
// Access" call to action.
export function DocumentAccessAction({ isAuthorized, requested, onView, isViewing }: DocumentAccessActionProps) {
// Trailing access control for a document entry: a "View" link to the viewer when
// authorized, a pending label when access was requested, otherwise a (currently
// inert) "Get Access" call to action.
export function DocumentAccessAction({ isAuthorized, requested, viewHref }: DocumentAccessActionProps) {
const { t } = useTranslation("documents");
if (isAuthorized) {
return (
<Button
variant="ghost"
color="neutral"
highContrast
iconStart={<ArrowSquareOutIcon />}
loading={isViewing}
onClick={onView}
>
<Link to={viewHref} variant="ghost" color="neutral" highContrast iconStart={<ArrowRightIcon />}>
{t("actions.view")}
</Button>
</Link>
);
}

View File

@@ -0,0 +1,50 @@
// 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 { DownloadSimpleIcon, FileIcon } from "@phosphor-icons/react";
import { Button } from "@probo/ui/src/v2/Button/Button";
import { useTranslation } from "react-i18next";
import { EmptyState } from "#/components/EmptyState/EmptyState";
interface DocumentDownloadFallbackProps {
onDownload: () => void;
}
// Body shown for file types that can't be previewed in the browser (office,
// data, text): offer a download instead.
export function DocumentDownloadFallback({ onDownload }: DocumentDownloadFallbackProps) {
const { t } = useTranslation("documents");
return (
<div className="grid h-full place-items-center bg-sand-3 p-8">
<EmptyState
icon={<FileIcon />}
title={t("viewer.previewUnavailable")}
description={t("viewer.downloadToView")}
action={(
<Button color="neutral" highContrast iconStart={<DownloadSimpleIcon />} onClick={onDownload}>
{t("viewer.download")}
</Button>
)}
/>
</div>
);
}

View File

@@ -33,16 +33,14 @@ interface DocumentEntryProps {
isAuthorized: boolean;
// Whether an access request is already pending for the entry.
requested: boolean;
// Opens the entry; only invoked when authorized.
onView: () => void;
// Whether the export/open is in flight.
isViewing: boolean;
// Route to the document viewer, used when authorized.
viewHref: string;
}
// 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.
export function DocumentEntry({ title, meta, isAuthorized, requested, onView, isViewing }: DocumentEntryProps) {
export function DocumentEntry({ title, meta, isAuthorized, requested, viewHref }: DocumentEntryProps) {
const { root, content } = documentListItem();
return (
@@ -55,12 +53,7 @@ export function DocumentEntry({ title, meta, isAuthorized, requested, onView, is
{meta}
</Text>
</div>
<DocumentAccessAction
isAuthorized={isAuthorized}
requested={requested}
onView={onView}
isViewing={isViewing}
/>
<DocumentAccessAction isAuthorized={isAuthorized} requested={requested} viewHref={viewHref} />
</div>
);
}

View File

@@ -21,15 +21,13 @@
import { useTranslation } from "react-i18next";
import { graphql, useFragment } from "react-relay";
import { useExportAndOpen } from "../_lib/useExportAndOpen";
import type { DocumentListItem_document$key } from "./__generated__/DocumentListItem_document.graphql";
import type { DocumentListItemExportMutation } from "./__generated__/DocumentListItemExportMutation.graphql";
import { DocumentEntry } from "./DocumentEntry";
const documentListItemFragment = graphql`
fragment DocumentListItem_document on Document @throwOnFieldError {
id
alias
title
documentType
isUserAuthorized
@@ -39,27 +37,15 @@ const documentListItemFragment = graphql`
}
`;
const exportDocumentMutation = graphql`
mutation DocumentListItemExportMutation($input: ExportDocumentPDFInput!) {
exportDocumentPDF(input: $input) {
data
}
}
`;
interface DocumentListItemProps {
documentKey: DocumentListItem_document$key;
}
// A single Probo document entry: title, its document type, and an access action
// that opens the exported PDF when the viewer is authorized.
// linking to the viewer when authorized.
export function DocumentListItem({ documentKey }: DocumentListItemProps) {
const { t } = useTranslation("documents");
const document = useFragment(documentListItemFragment, documentKey);
const [openDocument, isExporting] = useExportAndOpen<DocumentListItemExportMutation>(
exportDocumentMutation,
response => response.exportDocumentPDF.data,
);
return (
<DocumentEntry
@@ -67,8 +53,7 @@ export function DocumentListItem({ documentKey }: DocumentListItemProps) {
meta={t(`types.${document.documentType}`)}
isAuthorized={document.isUserAuthorized}
requested={document.access?.status === "REQUESTED"}
onView={() => openDocument({ input: { documentId: document.id } })}
isViewing={isExporting}
viewHref={`/documents/${encodeURIComponent(document.alias ?? document.id)}`}
/>
);
}

View File

@@ -0,0 +1,46 @@
// 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 { LockSimpleIcon } from "@phosphor-icons/react";
import { Button } from "@probo/ui/src/v2/Button/Button";
import { useTranslation } from "react-i18next";
import { EmptyState } from "#/components/EmptyState/EmptyState";
// Shown when the viewer resolves a document the visitor may not access. The
// Get Access CTA is display-only until the auth flow lands (see the list rows).
export function DocumentLocked() {
const { t } = useTranslation("documents");
return (
<div className="grid h-full place-items-center bg-sand-3 p-8">
<EmptyState
icon={<LockSimpleIcon />}
title={t("viewer.locked.title")}
description={t("viewer.locked.description")}
action={(
<Button color="neutral" highContrast iconStart={<LockSimpleIcon />}>
{t("actions.getAccess")}
</Button>
)}
/>
</div>
);
}

View File

@@ -0,0 +1,211 @@
// 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 { Toast } from "@base-ui/react/toast";
import {
CaretLeftIcon,
CaretRightIcon,
DownloadSimpleIcon,
MagnifyingGlassMinusIcon,
MagnifyingGlassPlusIcon,
ShareNetworkIcon,
SpinnerGapIcon,
} 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 { Separator } from "@probo/ui/src/v2/Separator/Separator";
import { Heading } from "@probo/ui/src/v2/typography/Heading";
import { Text } from "@probo/ui/src/v2/typography/Text";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
import { dataUriMimeType, downloadDataUri } from "../_lib/dataUri";
import { DocumentDownloadFallback } from "./DocumentDownloadFallback";
import type { PdfPreviewHandle } from "./PdfPreview";
import { PdfPreview } from "./PdfPreview";
import { documentViewer } from "./variants";
const MIN_SCALE = 0.5;
const MAX_SCALE = 3;
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
interface DocumentViewerProps {
// The document/file/report display name.
title: string;
// The exported base64 data URI, or null while it is still loading.
dataUri: string | null;
// File name used when downloading.
downloadName: string;
}
// Full-page document viewer: a header band with the title and a toolbar
// (page navigation + zoom for PDFs, share, download) above the scrollable body.
// PDFs render with react-pdf, images inline, and anything else offers a
// download.
export function DocumentViewer({ title, dataUri, downloadName }: DocumentViewerProps) {
const { t } = useTranslation("documents");
const toast = Toast.useToastManager();
const pdfRef = useRef<PdfPreviewHandle>(null);
const [numPages, setNumPages] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [scale, setScale] = useState(1);
const mimeType = dataUri ? dataUriMimeType(dataUri) : null;
const isPdf = mimeType === "application/pdf";
const isImage = mimeType?.startsWith("image/") ?? false;
const movePage = (direction: 1 | -1) => {
const next = clamp(currentPage + direction, 1, numPages);
pdfRef.current?.scrollToPage(next);
setCurrentPage(next);
};
const handleShare = () => {
navigator.clipboard.writeText(window.location.href).then(
() => toast.add({ title: t("viewer.linkCopied"), type: "success" }),
() => {},
);
};
const handleDownload = () => {
if (dataUri) {
downloadDataUri(dataUri, downloadName);
}
};
const slots = documentViewer();
return (
<div className={slots.root()}>
<HeaderBand flushBottomSpace>
<div className={slots.header()}>
<Link to="/documents" variant="ghost" color="neutral" size={1} iconStart={<CaretLeftIcon />} className={slots.back()}>
{t("viewer.back")}
</Link>
<Heading level={1} size={7} weight="medium" highContrast className="truncate">
{title}
</Heading>
<div className={slots.toolbar()}>
<div className={slots.toolbarStart()}>
{isPdf && (
<>
<div className={slots.controls()}>
<IconButton
variant="ghost"
color="neutral"
aria-label={t("viewer.previousPage")}
disabled={currentPage <= 1}
onClick={() => movePage(-1)}
>
<CaretLeftIcon />
</IconButton>
<Text size={2} color="neutral">
{t("viewer.pageOf", { current: currentPage, total: numPages })}
</Text>
<IconButton
variant="ghost"
color="neutral"
aria-label={t("viewer.nextPage")}
disabled={currentPage >= numPages}
onClick={() => movePage(1)}
>
<CaretRightIcon />
</IconButton>
</div>
<Separator orientation="vertical" className={slots.separator()} />
<div className={slots.controls()}>
<IconButton
variant="ghost"
color="neutral"
aria-label={t("viewer.zoomOut")}
onClick={() => setScale(value => clamp(value * 0.8, MIN_SCALE, MAX_SCALE))}
>
<MagnifyingGlassMinusIcon />
</IconButton>
<Text size={2} color="neutral">
{`${Math.round(scale * 100)}%`}
</Text>
<IconButton
variant="ghost"
color="neutral"
aria-label={t("viewer.zoomIn")}
onClick={() => setScale(value => clamp(value * 1.25, MIN_SCALE, MAX_SCALE))}
>
<MagnifyingGlassPlusIcon />
</IconButton>
</div>
</>
)}
</div>
<div className={slots.actions()}>
<Button variant="ghost" color="neutral" iconStart={<ShareNetworkIcon />} onClick={handleShare}>
{t("viewer.share")}
</Button>
<Separator orientation="vertical" className={slots.separator()} />
<Button
variant="ghost"
color="neutral"
iconStart={<DownloadSimpleIcon />}
disabled={dataUri == null}
onClick={handleDownload}
>
{t("viewer.download")}
</Button>
</div>
</div>
</div>
</HeaderBand>
<div className={slots.body()}>
{dataUri == null
? (
<div className={slots.stage()}>
<SpinnerGapIcon className={slots.spinner()} />
</div>
)
: isPdf
? (
<PdfPreview
ref={pdfRef}
file={dataUri}
scale={scale}
onNumPages={setNumPages}
onVisiblePageChange={setCurrentPage}
/>
)
: isImage
? (
<div className={slots.imageStage()}>
<img src={dataUri} alt={title} className={slots.image()} />
</div>
)
: <DocumentDownloadFallback onDownload={handleDownload} />}
</div>
</div>
);
}

View File

@@ -0,0 +1,113 @@
// 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 "react-pdf/dist/Page/AnnotationLayer.css";
import "react-pdf/dist/Page/TextLayer.css";
import { SpinnerGapIcon } from "@phosphor-icons/react";
import { times } from "@probo/helpers";
// Vite `?url` import resolves to the bundled worker URL (string); the import-x
// resolver doesn't understand the suffix, and `vite/client` types cover it.
// 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 { Document, Page, pdfjs } from "react-pdf";
import { pdfPreview } from "./variants";
// Bundle the pdf.js worker with the app (via Vite's `?url`) instead of loading
// it from a CDN, so the viewer works under a strict trust-center CSP.
pdfjs.GlobalWorkerOptions.workerSrc = workerSrc;
export interface PdfPreviewHandle {
scrollToPage: (page: number) => void;
}
interface PdfPreviewProps {
// Base64 data URI of the PDF to render.
file: string;
// Zoom factor applied to every page.
scale: number;
// Handle exposing imperative page navigation to the toolbar.
ref?: Ref<PdfPreviewHandle>;
// Reports the page count once the document has loaded.
onNumPages: (numPages: number) => void;
// Reports the page currently centered in the viewport.
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.
export function PdfPreview({ file, scale, ref, onNumPages, onVisiblePageChange }: PdfPreviewProps) {
const [numPages, setNumPages] = useState(0);
const wrapperRef = useRef<HTMLDivElement>(null);
const documentRef = useRef<ComponentRef<typeof Document>>(null);
useImperativeHandle(ref, () => ({
scrollToPage(page) {
const node = documentRef.current?.pages.current[page - 1];
node?.scrollIntoView({ behavior: "smooth", block: "start" });
},
}), []);
const resolveVisiblePage = () => {
const wrapper = wrapperRef.current;
const pages = documentRef.current?.pages.current;
if (!wrapper || !pages?.length) {
return;
}
const middle = wrapper.getBoundingClientRect().top + wrapper.clientHeight / 2;
for (let index = 0; index < pages.length; index += 1) {
const rect = pages[index].getBoundingClientRect();
if (rect.top <= middle && rect.bottom >= middle) {
onVisiblePageChange(index + 1);
return;
}
}
};
const slots = pdfPreview();
return (
<div ref={wrapperRef} onScrollEnd={resolveVisiblePage} className={slots.viewport()}>
<Document
ref={documentRef}
file={file}
className={slots.list()}
loading={(
<div className={slots.loading()}>
<SpinnerGapIcon className={slots.spinner()} />
</div>
)}
onLoadSuccess={(document) => {
setNumPages(document.numPages);
onNumPages(document.numPages);
onVisiblePageChange(1);
}}
>
{times(numPages, index => (
<Page key={index} pageNumber={index + 1} scale={scale} className={slots.page()} />
))}
</Document>
</div>
);
}

View File

@@ -20,15 +20,13 @@
import { graphql, useFragment } from "react-relay";
import { useExportAndOpen } from "../_lib/useExportAndOpen";
import type { TrustCenterFileListItem_file$key } from "./__generated__/TrustCenterFileListItem_file.graphql";
import type { TrustCenterFileListItemExportMutation } from "./__generated__/TrustCenterFileListItemExportMutation.graphql";
import { DocumentEntry } from "./DocumentEntry";
const trustCenterFileListItemFragment = graphql`
fragment TrustCenterFileListItem_file on TrustCenterFile @throwOnFieldError {
id
alias
name
category
isUserAuthorized
@@ -38,26 +36,14 @@ const trustCenterFileListItemFragment = graphql`
}
`;
const exportTrustCenterFileMutation = graphql`
mutation TrustCenterFileListItemExportMutation($input: ExportTrustCenterFileInput!) {
exportTrustCenterFile(input: $input) {
data
}
}
`;
interface TrustCenterFileListItemProps {
fileKey: TrustCenterFileListItem_file$key;
}
// A single uploaded trust-center file entry: name, its category, and an access
// action that opens the exported file when the viewer is authorized.
// action linking to the viewer when authorized.
export function TrustCenterFileListItem({ fileKey }: TrustCenterFileListItemProps) {
const file = useFragment(trustCenterFileListItemFragment, fileKey);
const [openFile, isExporting] = useExportAndOpen<TrustCenterFileListItemExportMutation>(
exportTrustCenterFileMutation,
response => response.exportTrustCenterFile.data,
);
return (
<DocumentEntry
@@ -65,8 +51,7 @@ export function TrustCenterFileListItem({ fileKey }: TrustCenterFileListItemProp
meta={file.category}
isAuthorized={file.isUserAuthorized}
requested={file.access?.status === "REQUESTED"}
onView={() => openFile({ input: { trustCenterFileId: file.id } })}
isViewing={isExporting}
viewHref={`/documents/${encodeURIComponent(file.alias ?? file.id)}`}
/>
);
}

View File

@@ -38,3 +38,34 @@ export const documentListItem = tv({
content: "flex min-w-0 flex-1 flex-col gap-0.5",
},
});
// PDF preview: a scrollable grey stage holding the stacked, centered pages.
export const pdfPreview = tv({
slots: {
viewport: "h-full overflow-auto bg-sand-3",
list: "flex flex-col items-center gap-4 py-8",
loading: "grid place-items-center py-16 text-sand-a10",
spinner: "size-6 animate-spin",
page: "shadow-3",
},
});
// Full-page viewer: fixed header band with the title and toolbar above a
// scrollable grey stage for the PDF / image / download-fallback body.
export const documentViewer = tv({
slots: {
root: "flex h-full 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",
controls: "flex items-center gap-1",
actions: "flex items-center gap-2",
separator: "h-6",
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",
image: "max-h-full max-w-full object-contain shadow-3",
spinner: "size-6 animate-spin text-sand-a10",
},
});

View File

@@ -0,0 +1,35 @@
// 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.
// The MIME type declared in a `data:<mime>;base64,...` URI (the shape the export
// mutations return), or null when the string isn't a recognizable data URI.
export function dataUriMimeType(dataUri: string): string | null {
return dataUri.match(/^data:([^;,]+)[;,]/)?.[1] ?? null;
}
// Triggers a browser download of a data URI under the given file name.
export function downloadDataUri(dataUri: string, filename: string): void {
const link = document.createElement("a");
link.href = dataUri;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
}

View File

@@ -0,0 +1,108 @@
// 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 { useEffect, useState } from "react";
import { graphql } from "react-relay";
import { useMutation } from "#/lib/relay/useMutation";
import type { useDocumentExportDocumentMutation } from "./__generated__/useDocumentExportDocumentMutation.graphql";
import type { useDocumentExportFileMutation } from "./__generated__/useDocumentExportFileMutation.graphql";
import type { useDocumentExportReportMutation } from "./__generated__/useDocumentExportReportMutation.graphql";
export type DocumentKind = "Document" | "TrustCenterFile" | "AuditReport";
const exportDocumentMutation = graphql`
mutation useDocumentExportDocumentMutation($input: ExportDocumentPDFInput!) {
exportDocumentPDF(input: $input) {
data
}
}
`;
const exportFileMutation = graphql`
mutation useDocumentExportFileMutation($input: ExportTrustCenterFileInput!) {
exportTrustCenterFile(input: $input) {
data
}
}
`;
const exportReportMutation = graphql`
mutation useDocumentExportReportMutation($input: ExportReportPDFInput!) {
exportReportPDF(input: $input) {
data
}
}
`;
interface DocumentExportState {
// The exported base64 data URI, or null while it is still loading.
dataUri: string | null;
isExporting: boolean;
}
// Exports the aliased node's (watermarked) bytes for the viewer. Fires the
// export mutation matching the node kind once `enabled`, and resets when the
// target id changes. Failures surface through the mutation notifier's toast.
export function useDocumentExport(kind: DocumentKind, id: string, enabled: boolean): DocumentExportState {
const [exportDocument, isExportingDocument] = useMutation<useDocumentExportDocumentMutation>(exportDocumentMutation);
const [exportFile, isExportingFile] = useMutation<useDocumentExportFileMutation>(exportFileMutation);
const [exportReport, isExportingReport] = useMutation<useDocumentExportReportMutation>(exportReportMutation);
const [dataUri, setDataUri] = useState<string | null>(null);
// Drop the previous document's bytes as soon as the target changes so a stale
// preview is never shown for the new document.
const [loadedId, setLoadedId] = useState(id);
if (loadedId !== id) {
setLoadedId(id);
setDataUri(null);
}
useEffect(() => {
if (!enabled || dataUri) {
return;
}
switch (kind) {
case "Document":
exportDocument({
variables: { input: { documentId: id } },
onCompleted: response => setDataUri(response.exportDocumentPDF.data),
}).catch(() => {});
break;
case "TrustCenterFile":
exportFile({
variables: { input: { trustCenterFileId: id } },
onCompleted: response => setDataUri(response.exportTrustCenterFile.data),
}).catch(() => {});
break;
case "AuditReport":
exportReport({
variables: { input: { reportId: id } },
onCompleted: response => setDataUri(response.exportReportPDF.data),
}).catch(() => {});
break;
}
}, [enabled, dataUri, kind, id, exportDocument, exportFile, exportReport]);
return { dataUri, isExporting: isExportingDocument || isExportingFile || isExportingReport };
}

View File

@@ -30,5 +30,22 @@
"description": "This trust center has not published any documents yet.",
"filteredTitle": "No documents match this filter",
"filteredDescription": "Try a different tab to see available documents."
},
"viewer": {
"back": "Documents",
"pageOf": "Page {{current}} of {{total}}",
"previousPage": "Previous page",
"nextPage": "Next page",
"zoomIn": "Zoom in",
"zoomOut": "Zoom out",
"share": "Share document",
"linkCopied": "Link copied to clipboard",
"download": "Download",
"previewUnavailable": "Preview unavailable",
"downloadToView": "This file type can't be previewed here. Download it to view.",
"locked": {
"title": "Access required",
"description": "You don't have access to this document yet."
}
}
}

View File

@@ -30,5 +30,22 @@
"description": "Ce trust center n'a pas encore publié de documents.",
"filteredTitle": "Aucun document ne correspond à ce filtre",
"filteredDescription": "Essayez un autre onglet pour voir les documents disponibles."
},
"viewer": {
"back": "Documents",
"pageOf": "Page {{current}} sur {{total}}",
"previousPage": "Page précédente",
"nextPage": "Page suivante",
"zoomIn": "Zoom avant",
"zoomOut": "Zoom arrière",
"share": "Partager le document",
"linkCopied": "Lien copié dans le presse-papiers",
"download": "Télécharger",
"previewUnavailable": "Aperçu indisponible",
"downloadToView": "Ce type de fichier ne peut pas être prévisualisé ici. Téléchargez-le pour le consulter.",
"locked": {
"title": "Accès requis",
"description": "Vous n'avez pas encore accès à ce document."
}
}
}

View File

@@ -22,6 +22,7 @@ import { lazy } from "@probo/react-lazy";
import type { AppRoute } from "@probo/routes";
import { DocumentsPageSkeleton } from "./DocumentsPageSkeleton";
import { DocumentViewerPageSkeleton } from "./DocumentViewerPageSkeleton";
export const documentRoutes = [
{
@@ -29,4 +30,9 @@ export const documentRoutes = [
Fallback: DocumentsPageSkeleton,
Component: lazy(() => import("./DocumentsPageLoader")),
},
{
path: "documents/:alias",
Fallback: DocumentViewerPageSkeleton,
Component: lazy(() => import("./DocumentViewerPageLoader")),
},
] satisfies AppRoute[];

2
package-lock.json generated
View File

@@ -36,9 +36,11 @@
"@probo/routes": "1.0.0",
"@probo/ui": "1.0.0",
"i18next": "^26.3.4",
"pdfjs-dist": "^5.4.296",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-i18next": "^17.0.8",
"react-pdf": "^10.3.0",
"react-relay": "^21.0.1",
"react-router": "^8.1.0",
"relay-runtime": "^21.0.1"

View File

@@ -0,0 +1,48 @@
// 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 { Text } from "../typography/Text";
import { Separator } from "./Separator";
export default {
title: "v2/Separator",
component: Separator,
};
export function Horizontal() {
return (
<div className="flex w-64 flex-col gap-3">
<Text size={2}>Above</Text>
<Separator />
<Text size={2}>Below</Text>
</div>
);
}
export function Vertical() {
return (
<div className="flex h-8 items-center gap-3">
<Text size={2}>Left</Text>
<Separator orientation="vertical" />
<Text size={2}>Right</Text>
</div>
);
}

View File

@@ -0,0 +1,42 @@
// 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 { Separator as BaseSeparator } from "@base-ui/react/separator";
import type { ComponentProps } from "react";
import { separator } from "./variants";
export type SeparatorProps = Omit<ComponentProps<typeof BaseSeparator>, "className"> & {
className?: string;
};
// A hairline rule (Radix "Separator"). Defaults to horizontal; pass
// `orientation="vertical"` for inline dividers (e.g. toolbar groups).
export function Separator(props: SeparatorProps) {
const { orientation = "horizontal", className, ...rest } = props;
return (
<BaseSeparator
orientation={orientation}
className={separator({ orientation, className })}
{...rest}
/>
);
}

View File

@@ -0,0 +1,36 @@
// 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 { tv } from "tailwind-variants/lite";
// Separator (Radix "Separator" over Base UI's Separator). A hairline rule that
// stretches along the cross axis of its flex parent when vertical.
export const separator = tv({
base: "shrink-0 bg-sand-a3",
variants: {
orientation: {
horizontal: "h-px w-full",
vertical: "w-px self-stretch",
},
},
defaultVariants: {
orientation: "horizontal",
},
});