diff --git a/openframe-frontend-core/src/components/help-center-pages/__tests__/trust-center-page.test.tsx b/openframe-frontend-core/src/components/help-center-pages/__tests__/trust-center-page.test.tsx
index 1513e80d0..555f4dec8 100644
--- a/openframe-frontend-core/src/components/help-center-pages/__tests__/trust-center-page.test.tsx
+++ b/openframe-frontend-core/src/components/help-center-pages/__tests__/trust-center-page.test.tsx
@@ -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';
@@ -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();
+ 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();
+ 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(
+ Why we build in the open}
+ />,
+ );
+ 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();
+ 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(
ReactNode;
}
-/** Sections that have content, in page order. */
-function visibleSections(data: TrustCenterPublic): Array<(typeof TRUST_CENTER_SECTIONS)[number]> {
- const hasContent: Record = {
- 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
@@ -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({
@@ -152,6 +144,7 @@ export function TrustCenterPage({
title = TRUST_CENTER_TITLE,
subtitle = TRUST_CENTER_TAGLINE,
ask,
+ beforeFaq,
}: TrustCenterPageProps) {
const { data, isLoading, error, reload } = useSelfFetch(endpoint, {
initialData,
@@ -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 `` — OpenFrame's
// `AppLayout`) and keeps the URL's hash on the section in view (`syncHash`;
@@ -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 = ;
// 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 = {
ai: {
lead: TRUST_SECTION_LEADS.ai,
@@ -239,6 +238,11 @@ export function TrustCenterPage({
contact: { render: () => 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 = ;
@@ -264,13 +268,16 @@ export function TrustCenterPage({
}
source={data.dataSource.name}
lastUpdated={data.syncedAt}
+ action={download}
/>
- ) : null}
+ ) : (
+ download
+ )}
- {sections.map(section => {
+ {columnSections.map(section => {
const view = views[section.id];
return (
@@ -302,9 +309,23 @@ export function TrustCenterPage({
}
return (
-
- {body}
-
+ <>
+
+ {body}
+
+ {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.
+
);
}
diff --git a/openframe-frontend-core/src/components/ui/download-button.tsx b/openframe-frontend-core/src/components/ui/download-button.tsx
new file mode 100644
index 000000000..5f7c67de4
--- /dev/null
+++ b/openframe-frontend-core/src/components/ui/download-button.tsx
@@ -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 & {
+ /** 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 (
+ }
+ href={href}
+ {...(href ? { openInNewTab: true, prefetch: false } : {})}
+ {...props}
+ >
+ {label}
+
+ );
+}
diff --git a/openframe-frontend-core/src/components/ui/index.ts b/openframe-frontend-core/src/components/ui/index.ts
index 95e54e6be..b5f86e1de 100644
--- a/openframe-frontend-core/src/components/ui/index.ts
+++ b/openframe-frontend-core/src/components/ui/index.ts
@@ -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';
diff --git a/openframe-frontend-core/src/types/trust-center.ts b/openframe-frontend-core/src/types/trust-center.ts
index b9fca2942..3f703dd13 100644
--- a/openframe-frontend-core/src/types/trust-center.ts
+++ b/openframe-frontend-core/src/types/trust-center.ts
@@ -188,6 +188,21 @@ export function isTrustCenterMonitored(
return Number.isFinite(synced) && nowMs - synced <= data.monitoredWindowMs;
}
+/**
+ * THE monitoring claim and its wording, judged against a clock: monitored,
+ * paused, or not enabled. The page reads it after mount (its own clock) and the
+ * PDF when it is generated, so both say the same thing in the same words.
+ */
+export function trustCenterMonitoringStatus(
+ data: Pick,
+ nowMs: number,
+): { status: 'success' | 'pending' | 'missing'; label: string } {
+ if (!data.connected) return { status: 'missing', label: 'Live control monitoring is not enabled yet' };
+ return isTrustCenterMonitored(data, nowMs)
+ ? { status: 'success', label: 'Controls continuously monitored' }
+ : { status: 'pending', label: 'Monitoring paused' };
+}
+
/** "SOC 2 Type II: In progress · ISO 27001: Planned" — the ONE framework summary line (chat card, RAG mapper). */
export function trustFrameworksSummary(
frameworks: ReadonlyArray>,
@@ -195,10 +210,16 @@ export function trustFrameworksSummary(
return frameworks.map(f => `${f.label}: ${trustFrameworkBadge(f)}`).join(' · ');
}
+/** A path BESIDE the page's `endpoint`, which may carry a query string (an embed proxy): `endpoint/segment?query`. */
+function besideTrustCenterEndpoint(endpoint: string, segment: string): string {
+ const [path, query] = endpoint.split('?', 2);
+ return `${path}/${segment}${query ? `?${query}` : ''}`;
+}
+
/**
* Where a public document opens: its own link, or — for a file — the hub's
- * documents route beside the page's `endpoint` (which may carry a query string,
- * e.g. an embed proxy). `null` for a document that is only available on request.
+ * documents route beside the page's `endpoint`. `null` for a document that is
+ * only available on request.
*/
export function trustCenterDocumentUrl(
endpoint: string,
@@ -206,8 +227,35 @@ export function trustCenterDocumentUrl(
): string | null {
if (document.access !== 'public') return null;
if (document.externalUrl) return document.externalUrl;
- const [path, query] = endpoint.split('?', 2);
- return `${path}/documents/${encodeURIComponent(document.id)}${query ? `?${query}` : ''}`;
+ return besideTrustCenterEndpoint(endpoint, `documents/${encodeURIComponent(document.id)}`);
+}
+
+/** Where the page downloads as a PDF: the hub's `pdf` route beside the page's `endpoint`. */
+export function trustCenterPdfUrl(endpoint: string): string {
+ return besideTrustCenterEndpoint(endpoint, 'pdf');
+}
+
+/**
+ * THE rule for which sections a trust center shows, in reading order: a section
+ * with nothing to list is left out; Contact always shows. The page and its PDF
+ * both read it, so the two can never disagree about what is published.
+ */
+export function visibleTrustCenterSections(
+ data: Pick<
+ TrustCenterPublic,
+ 'aiPractices' | 'frameworks' | 'controlDomains' | 'documents' | 'subprocessors' | 'faqs'
+ >,
+): Array<(typeof TRUST_CENTER_SECTIONS)[number]> {
+ const hasContent: Record = {
+ 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]);
}
// ---------------------------------------------------------------------------