Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ import {
type TrustCenterControlsPage,
type TrustCenterPublic,
} from '../../../types/trust-center';
import { TRUST_CENTER_FIXTURE_WINDOW_MS, makeTrustCenterData } from '../__fixtures__/trust-center';
import {
TRUST_CENTER_FIXTURE_FAQ,
TRUST_CENTER_FIXTURE_WINDOW_MS,
makeTrustCenterData,
} from '../__fixtures__/trust-center';
import { TrustCenterPage, TrustCenterPageSkeleton } from '../trust-center-page';
import { TRUST_SECTION_LEADS } from '../trust-center-sections';

Expand Down Expand Up @@ -208,6 +212,40 @@ describe('TrustCenterPage', () => {
expect(screen.getByRole('link', { name: 'View Privacy policy' })).toHaveAttribute('target', '_blank');
});

it("offers the page as a PDF: ONE small download button in the status line, the hub's pdf route beside the endpoint, in a new tab", () => {
render(<TrustCenterPage endpoint="/content/api/trust-center?tenant=a" initialData={makeData()} />);
const link = screen.getByRole('link', { name: 'Download PDF' });
expect(link).toHaveAttribute('href', '/content/api/trust-center/pdf?tenant=a');
expect(link).toHaveAttribute('target', '_blank');
// One control only: never also a header action or part of the phone's fixed bottom bar (those are for Request access).
expect(screen.getAllByText('Download PDF')).toHaveLength(1);
});

it('still offers the PDF when monitoring is not connected (no source line to end)', () => {
render(<TrustCenterPage initialData={makeData({ connected: false, syncedAt: null })} />);
expect(screen.getByRole('link', { name: 'Download PDF' })).toHaveAttribute('href', '/api/trust-center/pdf');
expect(screen.queryByText(/Data synced from/)).toBeNull();
});

it('with host content before it, the FAQ is the last thing on the page, after that content', () => {
render(
<TrustCenterPage
initialData={makeData({ faqs: [TRUST_CENTER_FIXTURE_FAQ] })}
beforeFaq={<h2>Why we build in the open</h2>}
/>,
);
const headings = screen.getAllByRole('heading', { level: 2 }).map(h => h.textContent);
expect(headings.indexOf('Why we build in the open')).toBeGreaterThan(headings.indexOf('Contact'));
expect(headings.indexOf('FAQ')).toBeGreaterThan(headings.indexOf('Why we build in the open'));
expect(headings.filter(h => h === 'FAQ')).toHaveLength(1);
});

it('without host content the FAQ closes the sections column, as before', () => {
render(<TrustCenterPage initialData={makeData({ faqs: [TRUST_CENTER_FIXTURE_FAQ] })} />);
const headings = screen.getAllByRole('heading', { level: 2 }).map(h => h.textContent);
expect(headings.indexOf('FAQ')).toBeGreaterThan(headings.indexOf('Contact'));
});

it('hides empty sections and the request CTA when nothing is gated', () => {
render(
<TrustCenterPage
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*
* ONE page, not tabs (2026 trust-center practice: buyers skim and Ctrl-F, and a
* questionnaire needs a deep link per section): hero with the monitoring status
* and the two actions, then anchored sections in reading order
* (with the page's PDF as a quiet link at its end) and the Request access action, then anchored sections in reading order
* (`TRUST_CENTER_SECTIONS`: AI & data use → compliance → controls → documents →
* subprocessors → FAQ → questions) with a sticky section rail on desktop
* (`StickySectionNav` + `useScrollSpy`, the vendor-page / DocViewer pattern).
Expand Down Expand Up @@ -34,11 +34,12 @@ import { useSelfFetch } from '../../hooks/use-self-fetch';
import {
TRUST_CENTER_API_PATH,
TRUST_CENTER_CACHE_SECONDS,
TRUST_CENTER_SECTIONS,
TRUST_CENTER_TAGLINE,
TRUST_CENTER_TITLE,
isTrustCenterMonitored,
trustCenterMonitoringStatus,
trustCenterDocumentUrl,
trustCenterPdfUrl,
visibleTrustCenterSections,
type TrustCenterControlsPage,
type TrustCenterPublic,
type TrustCenterSectionId,
Expand All @@ -50,6 +51,7 @@ import { PageShell } from '../layout/article-detail-layout';
import { PageLayout } from '../layout/page-layout';
import { StickySectionNav } from '../navigation/sticky-section-nav';
import { DataAttribution } from '../ui/data-attribution';
import { DownloadButton } from '../ui/download-button';
import { EntityImage } from '../ui/entity-image';
import { LoadError } from '../ui/error-state';
import { StatusIndicator } from '../ui/status-indicator';
Expand Down Expand Up @@ -85,6 +87,13 @@ export interface TrustCenterPageProps {
subtitle?: string;
/** The FAQ section's "ask the assistant" card (`FaqSection`'s `ask`): the host states its topic. Absent: no card. */
ask?: FaqSectionProps['ask'];
/**
* Host content shown after the trust sections and BEFORE the FAQ (the hub's
* own "why we build in the open" sections). The FAQ is the last thing on a
* page, so with this the page draws: trust sections, this content at the
* page's full width, then the FAQ. Absent: the FAQ closes the sections column.
*/
beforeFaq?: ReactNode;
}

/** One section's page content. Its `h2` is the `TRUST_CENTER_SECTIONS` label — never a literal here. */
Expand All @@ -93,20 +102,6 @@ interface TrustSectionView {
render: (data: TrustCenterPublic) => ReactNode;
}

/** Sections that have content, in page order. */
function visibleSections(data: TrustCenterPublic): Array<(typeof TRUST_CENTER_SECTIONS)[number]> {
const hasContent: Record<TrustCenterSectionId, boolean> = {
ai: data.aiPractices.length > 0,
compliance: data.frameworks.length > 0,
controls: data.controlDomains.length > 0,
documents: data.documents.length > 0,
subprocessors: data.subprocessors.length > 0,
faq: data.faqs.length > 0,
contact: true,
};
return TRUST_CENTER_SECTIONS.filter(section => hasContent[section.id]);
}

/**
* The Controls section's first answer, from the page's own server data: the
* first category's controls with no query — exactly what the controls endpoint
Expand Down Expand Up @@ -136,12 +131,9 @@ function monitoringStatus(
hydrated: boolean,
nowMs: number,
): { status: 'success' | 'pending' | 'missing'; label: string } {
if (!data.connected) return { status: 'missing', label: 'Live control monitoring is not enabled yet' };
// Before mount the clock is the server's, so the text claims nothing either way.
if (!hydrated) return { status: 'missing', label: 'Checking monitoring status' };
return isTrustCenterMonitored(data, nowMs)
? { status: 'success', label: 'Controls continuously monitored' }
: { status: 'pending', label: 'Monitoring paused' };
if (data.connected && !hydrated) return { status: 'missing', label: 'Checking monitoring status' };
return trustCenterMonitoringStatus(data, nowMs);
}

export function TrustCenterPage({
Expand All @@ -152,6 +144,7 @@ export function TrustCenterPage({
title = TRUST_CENTER_TITLE,
subtitle = TRUST_CENTER_TAGLINE,
ask,
beforeFaq,
}: TrustCenterPageProps) {
const { data, isLoading, error, reload } = useSelfFetch<TrustCenterPublic>(endpoint, {
initialData,
Expand Down Expand Up @@ -180,7 +173,7 @@ export function TrustCenterPage({
documentTitle: null,
});

const sections = useMemo(() => (data ? visibleSections(data) : []), [data]);
const sections = useMemo(() => (data ? visibleTrustCenterSections(data) : []), [data]);
// Anchors. The scroll spy lights the rail from whatever scrolls the sections
// (the window, or a host shell's `<main overflow-y-auto>` — OpenFrame's
// `AppLayout`) and keeps the URL's hash on the section in view (`syncHash`;
Expand All @@ -205,9 +198,15 @@ export function TrustCenterPage({
const actions = hasGatedDocuments
? [{ label: 'Request access', variant: 'accent' as const, onClick: () => openRequest(null) }]
: undefined;
// The page as a file (the hub's `pdf` route beside `endpoint`). An occasional
// utility on a read-only page, so it is NOT a header action (a button there,
// and a fixed bar on a phone, are for the page's task: Request access). It is
// the shared small `DownloadButton` at the end of the status line under the
// title, at every width.
const download = <DownloadButton label="Download PDF" href={trustCenterPdfUrl(endpoint)} />;

// Section id → its content. Titles come from `TRUST_CENTER_SECTIONS`; which
// sections show comes from `visibleSections` — this map only says what each renders.
// sections show comes from `visibleTrustCenterSections` — this map only says what each renders.
const views: Record<TrustCenterSectionId, TrustSectionView> = {
ai: {
lead: TRUST_SECTION_LEADS.ai,
Expand Down Expand Up @@ -239,6 +238,11 @@ export function TrustCenterPage({
contact: { render: () => <ContactSection onContact={() => openRequest(null)} /> },
};

// With host content before it, the FAQ leaves the sections column and closes the page (below).
const faqSection = sections.find(section => section.id === 'faq');
const faqLast = Boolean(beforeFaq) && Boolean(faqSection);
const columnSections = faqLast ? sections.filter(section => section.id !== 'faq') : sections;

let body: ReactNode;
if (error && !data) {
body = <LoadError message="Could not load the trust center" onRetry={reload} />;
Expand All @@ -264,13 +268,16 @@ export function TrustCenterPage({
}
source={data.dataSource.name}
lastUpdated={data.syncedAt}
action={download}
/>
) : null}
) : (
download
)}
</div>

<div className={TRUST_BODY_GRID_CLASS}>
<div className={TRUST_SECTIONS_COLUMN_CLASS}>
{sections.map(section => {
{columnSections.map(section => {
const view = views[section.id];
return (
<TrustSection key={section.id} id={section.id} title={section.label} lead={view.lead}>
Expand Down Expand Up @@ -302,9 +309,23 @@ export function TrustCenterPage({
}

return (
<TrustCenterChrome shell={shell} title={title} subtitle={subtitle} backButton={backButton} actions={actions}>
{body}
</TrustCenterChrome>
<>
<TrustCenterChrome shell={shell} title={title} subtitle={subtitle} backButton={backButton} actions={actions}>
{body}
</TrustCenterChrome>
{beforeFaq}
{faqLast && faqSection && data ? (
// The page's own content box at its FULL width (no section-rail column): a closing FAQ takes the
// same space the FAQ block takes at the foot of every other page.
<div className={shell ? 'bg-ods-bg' : undefined}>
<div className={shell ? 'page-shell-content mx-auto max-w-[1920px]' : 'page-shell-content'}>
<TrustSection id={faqSection.id} title={faqSection.label} lead={views.faq.lead}>
{views.faq.render(data)}
</TrustSection>
</div>
</div>
) : null}
</>
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ const BODY_TEXT = 'text-h6 text-ods-text-secondary';
* beside the section rail.
*/
export const TRUST_STATUS_ROW_CLASS =
'flex flex-col gap-[var(--spacing-system-xs)] content-md:flex-row content-md:items-center content-md:justify-between';
'flex flex-col gap-[var(--spacing-system-xsf)] content-md:flex-row content-md:items-center content-md:justify-between';
Comment thread
michaelassraf marked this conversation as resolved.
export const TRUST_BODY_GRID_CLASS =
'grid grid-cols-1 gap-[var(--spacing-system-xl)] content-lg:grid-cols-[minmax(0,1fr)_12rem]';
export const TRUST_SECTIONS_COLUMN_CLASS = 'flex min-w-0 flex-col gap-[var(--spacing-system-xxl)]';
Expand Down Expand Up @@ -675,7 +675,7 @@ export function TrustCenterSkeleton() {
<TextSkeleton.Body className="w-44" />
</div>
{/* `DataAttribution`'s own layout: two lines below `sm`, one row above it. */}
<div className="flex shrink-0 flex-col gap-[var(--spacing-system-xxs)] content-sm:flex-row content-sm:items-center content-sm:gap-[var(--spacing-system-sf)]">
<div className="flex shrink-0 flex-col gap-[var(--spacing-system-xsf)] content-sm:flex-row content-sm:items-center content-sm:gap-[var(--spacing-system-sf)]">
Comment thread
michaelassraf marked this conversation as resolved.
<div className="flex items-center gap-[var(--spacing-system-xsf)]">
<UnifiedSkeleton className="size-4 rounded" />
<TextSkeleton.Body className="w-36" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { DownloadButton } from '../download-button';

describe('DownloadButton', () => {
it('with an href it is a link that opens the file in a new tab', () => {
render(<DownloadButton label="Download PDF" href="/api/trust-center/pdf" />);
const link = screen.getByRole('link', { name: 'Download PDF' });
expect(link).toHaveAttribute('href', '/api/trust-center/pdf');
expect(link).toHaveAttribute('target', '_blank');
});

it('with an onClick the host downloads the file itself', () => {
const onClick = vi.fn();
render(<DownloadButton label="Download PDF" onClick={onClick} />);
fireEvent.click(screen.getByRole('button', { name: 'Download PDF' }));
expect(onClick).toHaveBeenCalledTimes(1);
});

it('is the regular small quiet button unless the host says otherwise', () => {
const { rerender } = render(<DownloadButton label="Download PDF" onClick={() => {}} />);
const regular = screen.getByRole('button', { name: 'Download PDF' }).className;
rerender(<DownloadButton label="Download PDF" variant="outline" onClick={() => {}} />);
expect(screen.getByRole('button', { name: 'Download PDF' }).className).not.toBe(regular);
});
});
25 changes: 19 additions & 6 deletions openframe-frontend-core/src/components/ui/data-attribution.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@ export interface DataAttributionProps {
source: string;
/** ISO instant of the last successful sync, or null when it never ran. */
lastUpdated: string | null;
/** One control at the end of the line: a way to take this data away (a `DownloadButton` for a file of it). */
action?: ReactNode;
}

const LINE_CLASS =
'flex shrink-0 flex-col gap-[var(--spacing-system-xsf)] text-ods-text-primary text-h6 content-sm:flex-row content-sm:items-center content-sm:gap-[var(--spacing-system-sf)]';
const ITEM_CLASS = 'flex items-center gap-[var(--spacing-system-xsf)]';

function Separator() {
return (
<span className="hidden text-ods-text-secondary content-sm:inline" aria-hidden="true">
&middot;
</span>
);
}

/**
Expand All @@ -16,17 +30,16 @@ export interface DataAttributionProps {
* UTC (`formatDate`), so a server-rendered page and its hydrated copy print the
* same text.
*/
export function DataAttribution({ icon, source, lastUpdated }: DataAttributionProps) {
export function DataAttribution({ icon, source, lastUpdated, action }: DataAttributionProps) {
return (
<div className="flex shrink-0 flex-col gap-[var(--spacing-system-xxs)] text-ods-text-primary text-h6 content-sm:flex-row content-sm:items-center content-sm:gap-[var(--spacing-system-sf)]">
<div className="flex items-center gap-[var(--spacing-system-xsf)]">
<div className={LINE_CLASS}>
<div className={ITEM_CLASS}>
{icon}
<span>Data synced from {source}</span>
</div>
<span className="hidden text-ods-text-secondary content-sm:inline" aria-hidden="true">
&middot;
</span>
<Separator />
<span className="text-ods-text-secondary">Last updated: {lastUpdated ? formatDate(lastUpdated) : 'Never'}</span>
{action ? <div className="flex content-sm:pl-[var(--spacing-system-xs)]">{action}</div> : null}
</div>
);
}
34 changes: 34 additions & 0 deletions openframe-frontend-core/src/components/ui/download-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Download01Icon } from '../icons-v2-generated/interface/download-01-icon';
import { Button, type ButtonProps } from './button/button';

export type DownloadButtonProps = Omit<ButtonProps, 'leftIcon' | 'children'> & {
/** What is downloaded, as the button says it ("Download PDF"). */
label: string;
};

/**
* THE download button: a regular small `Button` in the quiet `link` variant
* (no surface, secondary text like the facts it sits beside, the accent on
* hover) with the download glyph, wherever a surface hands the reader a file of
* what it shows (the Trust Center's PDF, the margin report's PDF). One look and
* one size, so a download reads the same on every page and never louder than
* the page's own action.
*
* With `href` it is a link that opens in a new tab (an attachment downloads
* there without leaving the page). With `onClick` the host fetches the file
* itself and passes `loading` while it does.
*/
export function DownloadButton({ label, variant = 'link', size = 'small', href, ...props }: DownloadButtonProps) {
return (
<Button
variant={variant}
size={size}
leftIcon={<Download01Icon aria-hidden="true" />}
href={href}
{...(href ? { openInNewTab: true, prefetch: false } : {})}
{...props}
>
{label}
</Button>
);
}
1 change: 1 addition & 0 deletions openframe-frontend-core/src/components/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export * from './device-card';
export * from './device-card-compact';
export * from './entity-image';
export * from './data-attribution';
export * from './download-button';
export * from './compliance-badge';
export * from './feature-card';
export * from './feature-list';
Expand Down
Loading
Loading