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. +
+
+ + {views.faq.render(data)} + +
+
+ ) : null} + ); } diff --git a/openframe-frontend-core/src/components/help-center-pages/trust-center-sections.tsx b/openframe-frontend-core/src/components/help-center-pages/trust-center-sections.tsx index c3284237c..e2adc3414 100644 --- a/openframe-frontend-core/src/components/help-center-pages/trust-center-sections.tsx +++ b/openframe-frontend-core/src/components/help-center-pages/trust-center-sections.tsx @@ -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'; 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)]'; @@ -675,7 +675,7 @@ export function TrustCenterSkeleton() {
{/* `DataAttribution`'s own layout: two lines below `sm`, one row above it. */} -
+
diff --git a/openframe-frontend-core/src/components/ui/__tests__/download-button.test.tsx b/openframe-frontend-core/src/components/ui/__tests__/download-button.test.tsx new file mode 100644 index 000000000..43fb6946e --- /dev/null +++ b/openframe-frontend-core/src/components/ui/__tests__/download-button.test.tsx @@ -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(); + 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(); + 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( {}} />); + const regular = screen.getByRole('button', { name: 'Download PDF' }).className; + rerender( {}} />); + expect(screen.getByRole('button', { name: 'Download PDF' }).className).not.toBe(regular); + }); +}); diff --git a/openframe-frontend-core/src/components/ui/data-attribution.tsx b/openframe-frontend-core/src/components/ui/data-attribution.tsx index fb329b038..3e7a23c67 100644 --- a/openframe-frontend-core/src/components/ui/data-attribution.tsx +++ b/openframe-frontend-core/src/components/ui/data-attribution.tsx @@ -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 ( + + ); } /** @@ -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 ( -
-
+
+
{icon} Data synced from {source}
- + Last updated: {lastUpdated ? formatDate(lastUpdated) : 'Never'} + {action ?
{action}
: null}
); } 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 ( + + ); +} 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]); } // ---------------------------------------------------------------------------