@flamingo-stack/openframe-frontend-core 0.0.722 → 0.0.724

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/dist/{chunk-XJYAUIOL.cjs → chunk-2A4AGW7Z.cjs} +11 -11
  2. package/dist/{chunk-65C4WHG4.js → chunk-2HLGX6JF.js} +5 -5
  3. package/dist/{chunk-5K6YKRP5.cjs → chunk-3MMKIVWZ.cjs} +23 -23
  4. package/dist/{chunk-P7Y626YJ.cjs → chunk-3NVSC3BU.cjs} +687 -611
  5. package/dist/{chunk-VSYQJCNR.js → chunk-4N5DLIEC.js} +1 -1
  6. package/dist/{chunk-GYSTZD6X.cjs → chunk-5CJGNPTT.cjs} +35 -35
  7. package/dist/{chunk-AWWLEYAU.js → chunk-6BKDXUOQ.js} +6 -6
  8. package/dist/{chunk-BUDZWXRG.js → chunk-6QO6BIX7.js} +1 -1
  9. package/dist/{chunk-FDWYBJYD.cjs → chunk-7AXOAA5T.cjs} +3 -3
  10. package/dist/{chunk-SZF7ENIW.js → chunk-7CNGNPBI.js} +3 -3
  11. package/dist/{chunk-6HRQLM6T.cjs → chunk-A4WHVIYZ.cjs} +17 -17
  12. package/dist/{chunk-VV5INIQB.cjs → chunk-BIWG3CYN.cjs} +3 -3
  13. package/dist/{chunk-I3UUMRO7.js → chunk-BWMEQ7IZ.js} +1 -1
  14. package/dist/{chunk-7UZ2JZJY.js → chunk-CJENFR5T.js} +29 -3
  15. package/dist/{chunk-IITZ6JSC.cjs → chunk-DGNGABNG.cjs} +33 -7
  16. package/dist/{chunk-KWBH63GV.cjs → chunk-FR4MFSH5.cjs} +19 -19
  17. package/dist/{chunk-DWSBTGKT.cjs → chunk-GRB5HVEV.cjs} +2610 -2125
  18. package/dist/{chunk-JQ55FFIK.cjs → chunk-IPUDFAU5.cjs} +31 -31
  19. package/dist/{chunk-F33RMJOL.js → chunk-IUQKIU5A.js} +6 -6
  20. package/dist/{chunk-LV6UJD2K.cjs → chunk-JKWQWHNV.cjs} +6 -1
  21. package/dist/{chunk-VC7COEWF.cjs → chunk-JQ7A7PO5.cjs} +76 -76
  22. package/dist/{chunk-BCU6UUNQ.js → chunk-KKO4GRM3.js} +4 -4
  23. package/dist/{chunk-RVQV6J5K.cjs → chunk-L5CAET76.cjs} +64 -64
  24. package/dist/{chunk-WY4SLTMZ.js → chunk-MRBPJ4NB.js} +1 -1
  25. package/dist/{chunk-IF72N4PE.js → chunk-MU5F5QXJ.js} +6 -6
  26. package/dist/{chunk-NMGWNUKB.js → chunk-N2PX26BZ.js} +2 -2
  27. package/dist/{chunk-NFABAOMD.cjs → chunk-N4NGKXUU.cjs} +15 -15
  28. package/dist/{chunk-ETQVXIYW.js → chunk-NLZZUWR6.js} +4 -4
  29. package/dist/{chunk-ZMHP4Y6M.js → chunk-OFOBVPDV.js} +2 -2
  30. package/dist/{chunk-DX3W7DPC.js → chunk-OWFDXYEO.js} +1 -1
  31. package/dist/{chunk-HA2ASGJM.js → chunk-PF4WAKCS.js} +4 -4
  32. package/dist/{chunk-GAFK3WNT.cjs → chunk-QJ67GFF3.cjs} +32 -32
  33. package/dist/{chunk-KBPGSPXQ.js → chunk-QN3OXRTM.js} +4492 -4007
  34. package/dist/{chunk-ZHC6MDGF.cjs → chunk-QUI7DKBW.cjs} +41 -41
  35. package/dist/{chunk-E7NIZXDT.js → chunk-S4SVKXHN.js} +88 -12
  36. package/dist/{chunk-VYHCN5NU.cjs → chunk-SOTXV3T5.cjs} +3 -3
  37. package/dist/{chunk-MJ4SXOG2.cjs → chunk-SVDXNMBL.cjs} +16 -16
  38. package/dist/{chunk-UYMTAFHG.js → chunk-VLPGV5WH.js} +1 -1
  39. package/dist/{chunk-SZ3CBFSH.js → chunk-XDTD6YGG.js} +7 -7
  40. package/dist/{chunk-VU7RU3LL.cjs → chunk-YKV45BSJ.cjs} +4 -4
  41. package/dist/{chunk-XR7NVJXS.cjs → chunk-ZKL52ALL.cjs} +3 -3
  42. package/dist/{chunk-EWED6ISQ.js → chunk-ZQKN2Z6W.js} +5 -0
  43. package/dist/{cloud-tenants-4Z36KWSX.js → cloud-tenants-5MINXS5J.js} +6 -6
  44. package/dist/{cloud-tenants-5NIC5VXK.cjs → cloud-tenants-UMZJZTMB.cjs} +7 -7
  45. package/dist/components/case-studies/index.cjs +20 -20
  46. package/dist/components/case-studies/index.js +7 -7
  47. package/dist/components/chat/entity-cards/dispatch.d.ts.map +1 -1
  48. package/dist/components/chat/index.cjs +5 -5
  49. package/dist/components/chat/index.js +4 -4
  50. package/dist/components/chat/utils/icon-library.d.ts +2 -0
  51. package/dist/components/chat/utils/icon-library.d.ts.map +1 -1
  52. package/dist/components/contact/index.cjs +8 -8
  53. package/dist/components/contact/index.js +7 -7
  54. package/dist/components/docs/index.cjs +11 -11
  55. package/dist/components/docs/index.js +10 -10
  56. package/dist/components/embeds/index.cjs +8 -8
  57. package/dist/components/embeds/index.js +7 -7
  58. package/dist/components/faq/index.cjs +8 -8
  59. package/dist/components/faq/index.js +7 -7
  60. package/dist/components/features/index.cjs +23 -7
  61. package/dist/components/features/index.d.ts +1 -0
  62. package/dist/components/features/index.d.ts.map +1 -1
  63. package/dist/components/features/index.js +22 -6
  64. package/dist/components/features/model-token-rates.d.ts +179 -0
  65. package/dist/components/features/model-token-rates.d.ts.map +1 -0
  66. package/dist/components/help-center-pages/index.cjs +130 -126
  67. package/dist/components/help-center-pages/index.js +47 -43
  68. package/dist/components/help-center-pages/trust-center-page.d.ts +34 -1
  69. package/dist/components/help-center-pages/trust-center-page.d.ts.map +1 -1
  70. package/dist/components/help-center-pages/trust-center-sections.d.ts +1 -1
  71. package/dist/components/help-center-pages/trust-center-sections.d.ts.map +1 -1
  72. package/dist/components/index.cjs +73 -55
  73. package/dist/components/index.js +36 -18
  74. package/dist/components/meeting-scheduler/index.cjs +32 -32
  75. package/dist/components/meeting-scheduler/index.js +6 -6
  76. package/dist/components/navigation/index.cjs +10 -10
  77. package/dist/components/navigation/index.js +9 -9
  78. package/dist/components/onboarding-guides/index.cjs +10 -10
  79. package/dist/components/onboarding-guides/index.js +9 -9
  80. package/dist/components/product-demo/index.cjs +7 -7
  81. package/dist/components/product-demo/index.js +7 -7
  82. package/dist/components/related-content/index.cjs +4 -4
  83. package/dist/components/related-content/index.js +3 -3
  84. package/dist/components/tickets/index.cjs +11 -11
  85. package/dist/components/tickets/index.js +10 -10
  86. package/dist/components/ui/data-attribution.d.ts +3 -1
  87. package/dist/components/ui/data-attribution.d.ts.map +1 -1
  88. package/dist/components/ui/download-button.d.ts +19 -0
  89. package/dist/components/ui/download-button.d.ts.map +1 -0
  90. package/dist/components/ui/index.cjs +9 -7
  91. package/dist/components/ui/index.d.ts +1 -0
  92. package/dist/components/ui/index.d.ts.map +1 -1
  93. package/dist/components/ui/index.js +8 -6
  94. package/dist/{devices-2TK3LAKM.js → devices-FSSUAUQN.js} +6 -6
  95. package/dist/{devices-7IKT72DK.cjs → devices-HOT7YSDN.cjs} +8 -8
  96. package/dist/index.cjs +53 -11
  97. package/dist/index.js +52 -10
  98. package/dist/{knowledge-HYSEV35H.cjs → knowledge-3N6HCFLU.cjs} +8 -8
  99. package/dist/{knowledge-3GJ4MU3V.js → knowledge-7HRUJX26.js} +6 -6
  100. package/dist/{logs-ZX3NADQC.cjs → logs-75M5YP6V.cjs} +15 -15
  101. package/dist/{logs-TATVM62H.js → logs-T25OE2QW.js} +6 -6
  102. package/dist/{policies-MRRKLF4Q.js → policies-4OK4XTD4.js} +6 -6
  103. package/dist/{policies-J4FSDKPI.cjs → policies-BTG7UJJC.cjs} +11 -11
  104. package/dist/{remote-session-Z2YCUPHU.cjs → remote-session-4DTOM3QD.cjs} +5 -5
  105. package/dist/{remote-session-WCLR47RN.js → remote-session-AZJXG7ZF.js} +3 -3
  106. package/dist/{software-update-KM46WUVQ.js → software-update-5SOV4FIR.js} +6 -6
  107. package/dist/{software-update-CXUBWYD2.cjs → software-update-OZURO4VK.cjs} +11 -11
  108. package/dist/types/index.cjs +27 -2
  109. package/dist/types/index.js +25 -3
  110. package/dist/types/trust-center.d.ts +19 -2
  111. package/dist/types/trust-center.d.ts.map +1 -1
  112. package/dist/utils/ai-providers.d.ts +29 -0
  113. package/dist/utils/ai-providers.d.ts.map +1 -0
  114. package/dist/utils/index.cjs +76 -2
  115. package/dist/utils/index.d.ts +2 -0
  116. package/dist/utils/index.d.ts.map +1 -1
  117. package/dist/utils/index.js +68 -3
  118. package/dist/utils/list-url.d.ts.map +1 -1
  119. package/dist/utils/model-token-rates.d.ts +55 -0
  120. package/dist/utils/model-token-rates.d.ts.map +1 -0
  121. package/dist/utils/source-icons.d.ts.map +1 -1
  122. package/package.json +1 -1
  123. package/src/components/chat/entity-cards/dispatch.tsx +3 -0
  124. package/src/components/chat/utils/icon-library.tsx +6 -0
  125. package/src/components/features/index.ts +1 -0
  126. package/src/components/features/model-token-rates.tsx +696 -0
  127. package/src/components/help-center-pages/trust-center-page.tsx +50 -29
  128. package/src/components/help-center-pages/trust-center-sections.tsx +2 -2
  129. package/src/components/ui/data-attribution.tsx +19 -6
  130. package/src/components/ui/download-button.tsx +34 -0
  131. package/src/components/ui/index.ts +1 -0
  132. package/src/types/trust-center.ts +52 -4
  133. package/src/utils/ai-providers.ts +46 -0
  134. package/src/utils/index.ts +2 -0
  135. package/src/utils/list-url.ts +1 -0
  136. package/src/utils/model-token-rates.ts +103 -0
  137. package/src/utils/source-icons.ts +3 -0
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * ONE page, not tabs (2026 trust-center practice: buyers skim and Ctrl-F, and a
7
7
  * questionnaire needs a deep link per section): hero with the monitoring status
8
- * and the two actions, then anchored sections in reading order
8
+ * (with the page's PDF as a quiet link at its end) and the Request access action, then anchored sections in reading order
9
9
  * (`TRUST_CENTER_SECTIONS`: AI & data use → compliance → controls → documents →
10
10
  * subprocessors → FAQ → questions) with a sticky section rail on desktop
11
11
  * (`StickySectionNav` + `useScrollSpy`, the vendor-page / DocViewer pattern).
@@ -34,11 +34,12 @@ import { useSelfFetch } from '../../hooks/use-self-fetch';
34
34
  import {
35
35
  TRUST_CENTER_API_PATH,
36
36
  TRUST_CENTER_CACHE_SECONDS,
37
- TRUST_CENTER_SECTIONS,
38
37
  TRUST_CENTER_TAGLINE,
39
38
  TRUST_CENTER_TITLE,
40
- isTrustCenterMonitored,
39
+ trustCenterMonitoringStatus,
41
40
  trustCenterDocumentUrl,
41
+ trustCenterPdfUrl,
42
+ visibleTrustCenterSections,
42
43
  type TrustCenterControlsPage,
43
44
  type TrustCenterPublic,
44
45
  type TrustCenterSectionId,
@@ -50,6 +51,7 @@ import { PageShell } from '../layout/article-detail-layout';
50
51
  import { PageLayout } from '../layout/page-layout';
51
52
  import { StickySectionNav } from '../navigation/sticky-section-nav';
52
53
  import { DataAttribution } from '../ui/data-attribution';
54
+ import { DownloadButton } from '../ui/download-button';
53
55
  import { EntityImage } from '../ui/entity-image';
54
56
  import { LoadError } from '../ui/error-state';
55
57
  import { StatusIndicator } from '../ui/status-indicator';
@@ -85,6 +87,13 @@ export interface TrustCenterPageProps {
85
87
  subtitle?: string;
86
88
  /** The FAQ section's "ask the assistant" card (`FaqSection`'s `ask`): the host states its topic. Absent: no card. */
87
89
  ask?: FaqSectionProps['ask'];
90
+ /**
91
+ * Host content shown after the trust sections and BEFORE the FAQ (the hub's
92
+ * own "why we build in the open" sections). The FAQ is the last thing on a
93
+ * page, so with this the page draws: trust sections, this content at the
94
+ * page's full width, then the FAQ. Absent: the FAQ closes the sections column.
95
+ */
96
+ beforeFaq?: ReactNode;
88
97
  }
89
98
 
90
99
  /** One section's page content. Its `h2` is the `TRUST_CENTER_SECTIONS` label — never a literal here. */
@@ -93,20 +102,6 @@ interface TrustSectionView {
93
102
  render: (data: TrustCenterPublic) => ReactNode;
94
103
  }
95
104
 
96
- /** Sections that have content, in page order. */
97
- function visibleSections(data: TrustCenterPublic): Array<(typeof TRUST_CENTER_SECTIONS)[number]> {
98
- const hasContent: Record<TrustCenterSectionId, boolean> = {
99
- ai: data.aiPractices.length > 0,
100
- compliance: data.frameworks.length > 0,
101
- controls: data.controlDomains.length > 0,
102
- documents: data.documents.length > 0,
103
- subprocessors: data.subprocessors.length > 0,
104
- faq: data.faqs.length > 0,
105
- contact: true,
106
- };
107
- return TRUST_CENTER_SECTIONS.filter(section => hasContent[section.id]);
108
- }
109
-
110
105
  /**
111
106
  * The Controls section's first answer, from the page's own server data: the
112
107
  * first category's controls with no query — exactly what the controls endpoint
@@ -136,12 +131,9 @@ function monitoringStatus(
136
131
  hydrated: boolean,
137
132
  nowMs: number,
138
133
  ): { status: 'success' | 'pending' | 'missing'; label: string } {
139
- if (!data.connected) return { status: 'missing', label: 'Live control monitoring is not enabled yet' };
140
134
  // Before mount the clock is the server's, so the text claims nothing either way.
141
- if (!hydrated) return { status: 'missing', label: 'Checking monitoring status' };
142
- return isTrustCenterMonitored(data, nowMs)
143
- ? { status: 'success', label: 'Controls continuously monitored' }
144
- : { status: 'pending', label: 'Monitoring paused' };
135
+ if (data.connected && !hydrated) return { status: 'missing', label: 'Checking monitoring status' };
136
+ return trustCenterMonitoringStatus(data, nowMs);
145
137
  }
146
138
 
147
139
  export function TrustCenterPage({
@@ -152,6 +144,7 @@ export function TrustCenterPage({
152
144
  title = TRUST_CENTER_TITLE,
153
145
  subtitle = TRUST_CENTER_TAGLINE,
154
146
  ask,
147
+ beforeFaq,
155
148
  }: TrustCenterPageProps) {
156
149
  const { data, isLoading, error, reload } = useSelfFetch<TrustCenterPublic>(endpoint, {
157
150
  initialData,
@@ -180,7 +173,7 @@ export function TrustCenterPage({
180
173
  documentTitle: null,
181
174
  });
182
175
 
183
- const sections = useMemo(() => (data ? visibleSections(data) : []), [data]);
176
+ const sections = useMemo(() => (data ? visibleTrustCenterSections(data) : []), [data]);
184
177
  // Anchors. The scroll spy lights the rail from whatever scrolls the sections
185
178
  // (the window, or a host shell's `<main overflow-y-auto>` — OpenFrame's
186
179
  // `AppLayout`) and keeps the URL's hash on the section in view (`syncHash`;
@@ -205,9 +198,15 @@ export function TrustCenterPage({
205
198
  const actions = hasGatedDocuments
206
199
  ? [{ label: 'Request access', variant: 'accent' as const, onClick: () => openRequest(null) }]
207
200
  : undefined;
201
+ // The page as a file (the hub's `pdf` route beside `endpoint`). An occasional
202
+ // utility on a read-only page, so it is NOT a header action (a button there,
203
+ // and a fixed bar on a phone, are for the page's task: Request access). It is
204
+ // the shared small `DownloadButton` at the end of the status line under the
205
+ // title, at every width.
206
+ const download = <DownloadButton label="Download PDF" href={trustCenterPdfUrl(endpoint)} />;
208
207
 
209
208
  // Section id → its content. Titles come from `TRUST_CENTER_SECTIONS`; which
210
- // sections show comes from `visibleSections` — this map only says what each renders.
209
+ // sections show comes from `visibleTrustCenterSections` — this map only says what each renders.
211
210
  const views: Record<TrustCenterSectionId, TrustSectionView> = {
212
211
  ai: {
213
212
  lead: TRUST_SECTION_LEADS.ai,
@@ -239,6 +238,11 @@ export function TrustCenterPage({
239
238
  contact: { render: () => <ContactSection onContact={() => openRequest(null)} /> },
240
239
  };
241
240
 
241
+ // With host content before it, the FAQ leaves the sections column and closes the page (below).
242
+ const faqSection = sections.find(section => section.id === 'faq');
243
+ const faqLast = Boolean(beforeFaq) && Boolean(faqSection);
244
+ const columnSections = faqLast ? sections.filter(section => section.id !== 'faq') : sections;
245
+
242
246
  let body: ReactNode;
243
247
  if (error && !data) {
244
248
  body = <LoadError message="Could not load the trust center" onRetry={reload} />;
@@ -264,13 +268,16 @@ export function TrustCenterPage({
264
268
  }
265
269
  source={data.dataSource.name}
266
270
  lastUpdated={data.syncedAt}
271
+ action={download}
267
272
  />
268
- ) : null}
273
+ ) : (
274
+ download
275
+ )}
269
276
  </div>
270
277
 
271
278
  <div className={TRUST_BODY_GRID_CLASS}>
272
279
  <div className={TRUST_SECTIONS_COLUMN_CLASS}>
273
- {sections.map(section => {
280
+ {columnSections.map(section => {
274
281
  const view = views[section.id];
275
282
  return (
276
283
  <TrustSection key={section.id} id={section.id} title={section.label} lead={view.lead}>
@@ -302,9 +309,23 @@ export function TrustCenterPage({
302
309
  }
303
310
 
304
311
  return (
305
- <TrustCenterChrome shell={shell} title={title} subtitle={subtitle} backButton={backButton} actions={actions}>
306
- {body}
307
- </TrustCenterChrome>
312
+ <>
313
+ <TrustCenterChrome shell={shell} title={title} subtitle={subtitle} backButton={backButton} actions={actions}>
314
+ {body}
315
+ </TrustCenterChrome>
316
+ {beforeFaq}
317
+ {faqLast && faqSection && data ? (
318
+ // The page's own content box at its FULL width (no section-rail column): a closing FAQ takes the
319
+ // same space the FAQ block takes at the foot of every other page.
320
+ <div className={shell ? 'bg-ods-bg' : undefined}>
321
+ <div className={shell ? 'page-shell-content mx-auto max-w-[1920px]' : 'page-shell-content'}>
322
+ <TrustSection id={faqSection.id} title={faqSection.label} lead={views.faq.lead}>
323
+ {views.faq.render(data)}
324
+ </TrustSection>
325
+ </div>
326
+ </div>
327
+ ) : null}
328
+ </>
308
329
  );
309
330
  }
310
331
 
@@ -72,7 +72,7 @@ const BODY_TEXT = 'text-h6 text-ods-text-secondary';
72
72
  * beside the section rail.
73
73
  */
74
74
  export const TRUST_STATUS_ROW_CLASS =
75
- 'flex flex-col gap-[var(--spacing-system-xs)] content-md:flex-row content-md:items-center content-md:justify-between';
75
+ 'flex flex-col gap-[var(--spacing-system-xsf)] content-md:flex-row content-md:items-center content-md:justify-between';
76
76
  export const TRUST_BODY_GRID_CLASS =
77
77
  'grid grid-cols-1 gap-[var(--spacing-system-xl)] content-lg:grid-cols-[minmax(0,1fr)_12rem]';
78
78
  export const TRUST_SECTIONS_COLUMN_CLASS = 'flex min-w-0 flex-col gap-[var(--spacing-system-xxl)]';
@@ -675,7 +675,7 @@ export function TrustCenterSkeleton() {
675
675
  <TextSkeleton.Body className="w-44" />
676
676
  </div>
677
677
  {/* `DataAttribution`'s own layout: two lines below `sm`, one row above it. */}
678
- <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)]">
678
+ <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)]">
679
679
  <div className="flex items-center gap-[var(--spacing-system-xsf)]">
680
680
  <UnifiedSkeleton className="size-4 rounded" />
681
681
  <TextSkeleton.Body className="w-36" />
@@ -8,6 +8,20 @@ export interface DataAttributionProps {
8
8
  source: string;
9
9
  /** ISO instant of the last successful sync, or null when it never ran. */
10
10
  lastUpdated: string | null;
11
+ /** One control at the end of the line: a way to take this data away (a `DownloadButton` for a file of it). */
12
+ action?: ReactNode;
13
+ }
14
+
15
+ const LINE_CLASS =
16
+ '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)]';
17
+ const ITEM_CLASS = 'flex items-center gap-[var(--spacing-system-xsf)]';
18
+
19
+ function Separator() {
20
+ return (
21
+ <span className="hidden text-ods-text-secondary content-sm:inline" aria-hidden="true">
22
+ &middot;
23
+ </span>
24
+ );
11
25
  }
12
26
 
13
27
  /**
@@ -16,17 +30,16 @@ export interface DataAttributionProps {
16
30
  * UTC (`formatDate`), so a server-rendered page and its hydrated copy print the
17
31
  * same text.
18
32
  */
19
- export function DataAttribution({ icon, source, lastUpdated }: DataAttributionProps) {
33
+ export function DataAttribution({ icon, source, lastUpdated, action }: DataAttributionProps) {
20
34
  return (
21
- <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)]">
22
- <div className="flex items-center gap-[var(--spacing-system-xsf)]">
35
+ <div className={LINE_CLASS}>
36
+ <div className={ITEM_CLASS}>
23
37
  {icon}
24
38
  <span>Data synced from {source}</span>
25
39
  </div>
26
- <span className="hidden text-ods-text-secondary content-sm:inline" aria-hidden="true">
27
- &middot;
28
- </span>
40
+ <Separator />
29
41
  <span className="text-ods-text-secondary">Last updated: {lastUpdated ? formatDate(lastUpdated) : 'Never'}</span>
42
+ {action ? <div className="flex content-sm:pl-[var(--spacing-system-xs)]">{action}</div> : null}
30
43
  </div>
31
44
  );
32
45
  }
@@ -0,0 +1,34 @@
1
+ import { Download01Icon } from '../icons-v2-generated/interface/download-01-icon';
2
+ import { Button, type ButtonProps } from './button/button';
3
+
4
+ export type DownloadButtonProps = Omit<ButtonProps, 'leftIcon' | 'children'> & {
5
+ /** What is downloaded, as the button says it ("Download PDF"). */
6
+ label: string;
7
+ };
8
+
9
+ /**
10
+ * THE download button: a regular small `Button` in the quiet `link` variant
11
+ * (no surface, secondary text like the facts it sits beside, the accent on
12
+ * hover) with the download glyph, wherever a surface hands the reader a file of
13
+ * what it shows (the Trust Center's PDF, the margin report's PDF). One look and
14
+ * one size, so a download reads the same on every page and never louder than
15
+ * the page's own action.
16
+ *
17
+ * With `href` it is a link that opens in a new tab (an attachment downloads
18
+ * there without leaving the page). With `onClick` the host fetches the file
19
+ * itself and passes `loading` while it does.
20
+ */
21
+ export function DownloadButton({ label, variant = 'link', size = 'small', href, ...props }: DownloadButtonProps) {
22
+ return (
23
+ <Button
24
+ variant={variant}
25
+ size={size}
26
+ leftIcon={<Download01Icon aria-hidden="true" />}
27
+ href={href}
28
+ {...(href ? { openInNewTab: true, prefetch: false } : {})}
29
+ {...props}
30
+ >
31
+ {label}
32
+ </Button>
33
+ );
34
+ }
@@ -108,6 +108,7 @@ export * from './device-card';
108
108
  export * from './device-card-compact';
109
109
  export * from './entity-image';
110
110
  export * from './data-attribution';
111
+ export * from './download-button';
111
112
  export * from './compliance-badge';
112
113
  export * from './feature-card';
113
114
  export * from './feature-list';
@@ -188,6 +188,21 @@ export function isTrustCenterMonitored(
188
188
  return Number.isFinite(synced) && nowMs - synced <= data.monitoredWindowMs;
189
189
  }
190
190
 
191
+ /**
192
+ * THE monitoring claim and its wording, judged against a clock: monitored,
193
+ * paused, or not enabled. The page reads it after mount (its own clock) and the
194
+ * PDF when it is generated, so both say the same thing in the same words.
195
+ */
196
+ export function trustCenterMonitoringStatus(
197
+ data: Pick<TrustCenterPublic, 'connected' | 'syncedAt' | 'monitoredWindowMs'>,
198
+ nowMs: number,
199
+ ): { status: 'success' | 'pending' | 'missing'; label: string } {
200
+ if (!data.connected) return { status: 'missing', label: 'Live control monitoring is not enabled yet' };
201
+ return isTrustCenterMonitored(data, nowMs)
202
+ ? { status: 'success', label: 'Controls continuously monitored' }
203
+ : { status: 'pending', label: 'Monitoring paused' };
204
+ }
205
+
191
206
  /** "SOC 2 Type II: In progress · ISO 27001: Planned" — the ONE framework summary line (chat card, RAG mapper). */
192
207
  export function trustFrameworksSummary(
193
208
  frameworks: ReadonlyArray<Pick<TrustCenterFramework, 'label' | 'monitoring' | 'percent'>>,
@@ -195,10 +210,16 @@ export function trustFrameworksSummary(
195
210
  return frameworks.map(f => `${f.label}: ${trustFrameworkBadge(f)}`).join(' · ');
196
211
  }
197
212
 
213
+ /** A path BESIDE the page's `endpoint`, which may carry a query string (an embed proxy): `endpoint/segment?query`. */
214
+ function besideTrustCenterEndpoint(endpoint: string, segment: string): string {
215
+ const [path, query] = endpoint.split('?', 2);
216
+ return `${path}/${segment}${query ? `?${query}` : ''}`;
217
+ }
218
+
198
219
  /**
199
220
  * Where a public document opens: its own link, or — for a file — the hub's
200
- * documents route beside the page's `endpoint` (which may carry a query string,
201
- * e.g. an embed proxy). `null` for a document that is only available on request.
221
+ * documents route beside the page's `endpoint`. `null` for a document that is
222
+ * only available on request.
202
223
  */
203
224
  export function trustCenterDocumentUrl(
204
225
  endpoint: string,
@@ -206,8 +227,35 @@ export function trustCenterDocumentUrl(
206
227
  ): string | null {
207
228
  if (document.access !== 'public') return null;
208
229
  if (document.externalUrl) return document.externalUrl;
209
- const [path, query] = endpoint.split('?', 2);
210
- return `${path}/documents/${encodeURIComponent(document.id)}${query ? `?${query}` : ''}`;
230
+ return besideTrustCenterEndpoint(endpoint, `documents/${encodeURIComponent(document.id)}`);
231
+ }
232
+
233
+ /** Where the page downloads as a PDF: the hub's `pdf` route beside the page's `endpoint`. */
234
+ export function trustCenterPdfUrl(endpoint: string): string {
235
+ return besideTrustCenterEndpoint(endpoint, 'pdf');
236
+ }
237
+
238
+ /**
239
+ * THE rule for which sections a trust center shows, in reading order: a section
240
+ * with nothing to list is left out; Contact always shows. The page and its PDF
241
+ * both read it, so the two can never disagree about what is published.
242
+ */
243
+ export function visibleTrustCenterSections(
244
+ data: Pick<
245
+ TrustCenterPublic,
246
+ 'aiPractices' | 'frameworks' | 'controlDomains' | 'documents' | 'subprocessors' | 'faqs'
247
+ >,
248
+ ): Array<(typeof TRUST_CENTER_SECTIONS)[number]> {
249
+ const hasContent: Record<TrustCenterSectionId, boolean> = {
250
+ ai: data.aiPractices.length > 0,
251
+ compliance: data.frameworks.length > 0,
252
+ controls: data.controlDomains.length > 0,
253
+ documents: data.documents.length > 0,
254
+ subprocessors: data.subprocessors.length > 0,
255
+ faq: data.faqs.length > 0,
256
+ contact: true,
257
+ };
258
+ return TRUST_CENTER_SECTIONS.filter(section => hasContent[section.id]);
211
259
  }
212
260
 
213
261
  // ---------------------------------------------------------------------------
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The AI model providers the product names: what each is called and the NAME of
3
+ * its mark in the icon set. THE one owner of both, for every surface that shows
4
+ * a provider (the token rates, a model picker). Server-safe: it holds names
5
+ * only, and the mark is resolved from the icon set by that name where it is
6
+ * drawn (`findIcon`), so no surface imports a provider's icon.
7
+ *
8
+ * A host that is told a provider's name or mark by its server states them on
9
+ * the record and they win. A provider this list has not met is named from its
10
+ * key, and its mark is looked up by the icon set's own naming
11
+ * (`<provider>-logo-grey`), so a logo added to the set shows with no change here.
12
+ */
13
+
14
+ export interface AiProvider {
15
+ /** The name people know the provider by. */
16
+ label: string;
17
+ /** The icon set's name of the provider's monochrome mark. */
18
+ icon: string;
19
+ }
20
+
21
+ /** Keyed by the provider key billing sends (`ANTHROPIC`). */
22
+ export const AI_PROVIDERS: Readonly<Record<string, AiProvider>> = {
23
+ ANTHROPIC: { label: 'Anthropic', icon: 'anthropic-logo-grey' },
24
+ OPENAI: { label: 'OpenAI', icon: 'openai-logo-grey' },
25
+ GOOGLE_GEMINI: { label: 'Google Gemini', icon: 'gemini-logo-grey' },
26
+ };
27
+
28
+ const words = (key: string): string[] =>
29
+ key
30
+ .toLowerCase()
31
+ .split(/[^a-z0-9]+/)
32
+ .filter(Boolean);
33
+
34
+ /**
35
+ * A provider by its key: what a host stated for it, else this list's entry,
36
+ * else its key read as a name and the mark the icon set would hold for it.
37
+ */
38
+ export function aiProvider(key: string, stated?: { label?: string | null; icon?: string | null } | null): AiProvider {
39
+ const known = AI_PROVIDERS[key];
40
+ const parts = words(key);
41
+ return {
42
+ label:
43
+ stated?.label || known?.label || parts.map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') || key,
44
+ icon: stated?.icon || known?.icon || `${parts.join('-')}-logo-grey`,
45
+ };
46
+ }
@@ -17,6 +17,8 @@ export {
17
17
  // source of truth). Re-exported here so existing callers that pull from
18
18
  // the barrel keep working without changing imports.
19
19
  export { formatDate, formatNumber, formatPrice, formatBytes, formatUrlForDisplay } from './format';
20
+ export * from './ai-providers';
21
+ export * from './model-token-rates';
20
22
  export { TRIAL_DAYS, TRIAL_LABELS } from './trial';
21
23
  export {
22
24
  VENDOR_CLASSIFICATIONS,
@@ -118,6 +118,7 @@ const BUILDERS: Record<string, (ids: string[], base: string) => string> = {
118
118
  prospect_call: (ids, b) => `${b}/api/prospect-calls?ids=${ids.join(',')}`,
119
119
  // The public website's own pages (live source; ChatRef-shaped items like the objects above).
120
120
  site_page: (ids, b) => `${b}/api/site-pages?ids=${ids.join(',')}`,
121
+ openframe_price: (ids, b) => `${b}/api/openframe-pricing?ids=${ids.join(',')}`,
121
122
  // The OpenMSP vendor directory (indexed source).
122
123
  vendor: (ids, b) => `${b}/api/vendors/cards?ids=${ids.join(',')}`,
123
124
  // Code intelligence — per-object card hydration, same `handleEntityCardList`
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The per-model AI token exchange rates: their shape and how a rate is written.
3
+ * Server-safe (no client code), so a host's server (a chat source, a page's
4
+ * metadata) states a rate exactly as the shared table (`ModelTokenRates`) does.
5
+ */
6
+
7
+ import { aiProvider } from './ai-providers';
8
+
9
+ /** One model's rates: OpenFrame tokens charged per ONE token of the model. */
10
+ export interface ModelTokenRate {
11
+ modelName: string;
12
+ /** The name people know the model by; the model's id stands in when it is missing. */
13
+ displayName?: string | null;
14
+ /** The provider's key (`ANTHROPIC`, `OPENAI`, `GOOGLE_GEMINI`); an unknown one is shown by name, without a mark. */
15
+ providerType: string;
16
+ /** The provider's name, when the host's server states one; else the shared provider list names it. */
17
+ providerLabel?: string | null;
18
+ /** The icon set's name of the provider's mark, when the host's server states one; else the shared provider list's. */
19
+ providerIcon?: string | null;
20
+ inputTokenRate: number;
21
+ outputTokenRate: number;
22
+ /** Charged for an input token read back from the prompt cache. Shown only where the table has room for it. */
23
+ cacheReadInputTokenRate?: number | null;
24
+ }
25
+
26
+ /** What a figure that cannot be stated is written as. */
27
+ export const TOKEN_RATE_EMPTY = '\u2014';
28
+
29
+ /**
30
+ * A rate as a multiplier. Two decimals from 0.1 up ("1.33×", "5×"), two
31
+ * significant digits under it ("0.033×"), so a cheap model's rate is never
32
+ * rounded to nothing.
33
+ */
34
+ export function formatTokenRate(value: number | null | undefined): string {
35
+ if (value == null || !Number.isFinite(value) || value <= 0) return TOKEN_RATE_EMPTY;
36
+ const rounded = value >= 0.1 ? Number(value.toFixed(2)) : Number(value.toPrecision(2));
37
+ return `${rounded}\u00d7`;
38
+ }
39
+
40
+ /** What OpenFrame tokens cost: `price` USD buys `tokens` of them. */
41
+ export interface TokenPrice {
42
+ tokens: number;
43
+ price: number;
44
+ }
45
+
46
+ /** What a number of OpenFrame tokens costs in USD at a price. Null without a price. */
47
+ export function tokenCost(tokens: number, tokenPrice: TokenPrice | null | undefined): number | null {
48
+ if (!tokenPrice || !(tokenPrice.tokens > 0) || !(tokenPrice.price > 0) || !(tokens >= 0)) return null;
49
+ return (tokens / tokenPrice.tokens) * tokenPrice.price;
50
+ }
51
+
52
+ /** The units a token count is shortened to, largest first. */
53
+ const TOKEN_AMOUNT_UNITS = [
54
+ { size: 1_000_000_000, suffix: 'B' },
55
+ { size: 1_000_000, suffix: 'M' },
56
+ { size: 1_000, suffix: 'K' },
57
+ ] as const;
58
+
59
+ /**
60
+ * A token count in short form: "850", "10K", "750K", "1.25M". The unit is chosen
61
+ * from the ROUNDED figure, so a count just under a unit reads as the next one
62
+ * ("1M", never "1000K").
63
+ */
64
+ export function formatTokenAmount(tokens: number | null | undefined): string {
65
+ if (tokens == null || !Number.isFinite(tokens) || tokens < 0) return TOKEN_RATE_EMPTY;
66
+ const whole = Math.round(tokens);
67
+ if (whole < 1_000) return String(whole);
68
+ for (const [index, unit] of TOKEN_AMOUNT_UNITS.entries()) {
69
+ if (whole < unit.size) continue;
70
+ const figure = Number((whole / unit.size).toPrecision(3));
71
+ // Rounded up to a thousand of this unit: it is one of the unit above.
72
+ const larger = TOKEN_AMOUNT_UNITS[index - 1];
73
+ return figure >= 1_000 && larger ? `1${larger.suffix}` : `${figure}${unit.suffix}`;
74
+ }
75
+ return String(whole);
76
+ }
77
+
78
+ /**
79
+ * How many of a model's tokens a balance of OpenFrame tokens runs at a rate:
80
+ * the balance divided by the rate. Null without a rate.
81
+ */
82
+ export function tokensForBalance(balance: number, rate: number | null | undefined): number | null {
83
+ if (rate == null || !Number.isFinite(rate) || rate <= 0 || !(balance > 0)) return null;
84
+ return balance / rate;
85
+ }
86
+
87
+ /** A provider's name: what the host stated for it, else the shared provider list's (`aiProvider`). */
88
+ export function tokenRateProviderLabel(providerType: string, stated?: string | null): string {
89
+ return aiProvider(providerType, { label: stated }).label;
90
+ }
91
+
92
+ /** The rates under their providers, in the order the host gave them (providers by first appearance). */
93
+ export function groupTokenRatesByProvider<T extends ModelTokenRate>(
94
+ rates: readonly T[],
95
+ ): { providerType: string; rates: T[] }[] {
96
+ const groups = new Map<string, T[]>();
97
+ for (const rate of rates) {
98
+ const group = groups.get(rate.providerType);
99
+ if (group) group.push(rate);
100
+ else groups.set(rate.providerType, [rate]);
101
+ }
102
+ return [...groups].map(([providerType, grouped]) => ({ providerType, rates: grouped }));
103
+ }
@@ -58,6 +58,7 @@ export const SOURCE_ICON_NAMES: Record<string, string> = {
58
58
  'trust-center': 'shield-check',
59
59
  // The public website's own pages (live source): the site's own mark, as a glyph in the slot's colour
60
60
  'website-pages': 'flamingo-logo-grey',
61
+ 'openframe-pricing': 'money-bill-dollar',
61
62
  // The OpenMSP vendor directory: the icon the site menu's "Tool directory" entry draws (it reads this one)
62
63
  vendors: 'package-search',
63
64
 
@@ -150,6 +151,7 @@ export const SOURCE_LABELS_BY_TABLE: Record<string, string> = {
150
151
  // Trust
151
152
  'trust-center': 'Trust Center',
152
153
  'website-pages': 'Website',
154
+ 'openframe-pricing': 'OpenFrame pricing',
153
155
  vendors: 'Vendor Directory',
154
156
 
155
157
  // Financials
@@ -255,6 +257,7 @@ export const DEFAULT_DOCUMENT_TYPE_TO_TABLE_ID: Record<string, string> = {
255
257
  // Trust
256
258
  trust_center: 'trust-center',
257
259
  site_page: 'website-pages',
260
+ openframe_price: 'openframe-pricing',
258
261
  vendor: 'vendors',
259
262
 
260
263
  // Financials