Trust Center
The public security page a B2B buyer asks for — audit dates measured against each report's own refresh cycle, a filterable subprocessor table with DPA links, plain-language practices, uptime, and when a human last reviewed the page.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/trust-center.jsonPrompt
Build a React + TypeScript + Tailwind "TrustCenter" block (lucide-react icons,
zod, shadcn Button / Badge / Input / Label).
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title; asOf (ISO instant);
reviewedAt (YYYY-MM-DD);
certifications: { id, label, auditedAt, reportUrl?, auditor?, scope?,
refreshMonths? }[];
subprocessors: { name, purpose, region, dpaUrl? }[];
practices: { id, title, body, icon? }[];
uptime?: { ninetyDayPct (0-100), statusUrl };
securityContact?: { label, href }; changeFeedUrl?; errorMessage? }.
- Props = z.infer of that schema plus locale, defaultRefreshMonths (12),
reviewStaleDays (90), defaultRegion, onRetry?, onSubscribe?(email), className,
and the remaining <section> props spread on the root; forwardRef the root.
- Days are plain YYYY-MM-DD and are parsed AND formatted in UTC — a day has no
instant, and rendering it in the reader's zone turns Feb 28 into Feb 27.
- asOf is injected, never read from the clock, so server and client agree.
Behavior
- Four first-class branches. loading = skeleton echoing the real anatomy plus an
sr-only role=status; empty = "nothing published yet" panel (ready with zero
certifications, zero subprocessors, zero practices and no uptime falls back to
it); error = errorMessage or a generic sentence plus a retry that is locked for
one burst by a ref (a state flag is not visible until the next render) and
re-arms on a timer cleared on unmount; ready = the page.
- Certification freshness is arithmetic, not a verdict: whole calendar months
between auditedAt and asOf (month difference, minus one when the day of the
month has not come round — 2025-06-30 to 2026-06-29 is 11 months, not 12),
compared with that report's own refreshMonths (SOC 2 Type II 12, an ISO 27001
certificate cycle 36). At or past it the card says "Refresh due" and names the
cycle; it never says compliant, certified, or failed. A future-dated or
unparseable audit date is labelled as such and printed verbatim. Zero whole
months reads "less than a month ago", never "this month" — 20 Jul to 18 Aug is
zero months and is not the same calendar month.
- The section header restates only what could be computed: "4 of 5 reports
inside the stated refresh window" counts readable, non-future dates on both
sides of the ratio and appends "· 1 date not readable" / "· 1 date after this
page's as-of date" for the rest, and the whole line is dropped when asOf
itself is unparseable. An age the page could not derive is never folded into
the overdue side of a count.
- A certification with no reportUrl renders a sentence — "Report not published
here. Request a copy from <securityContact>." — never a disabled button.
- Subprocessors: region facets sorted by count then name, with whole-percent
shares apportioned by largest remainder so the filter chips total exactly 100
(three of eight is 37.5%; rounding each on its own prints 101). The chips are a
role=radiogroup with roving tabindex — arrows move and select, Home/End jump,
Tab leaves; each chip overrides the button base's whitespace-nowrap/shrink-0
(whitespace-normal + h-auto + shrink) so a host-worded region such as "Asia
Pacific (Singapore, Tokyo, Sydney)" wraps instead of pushing the block
sideways. The table is a real <table> with scope=col/row inside an
overflow-x-auto wrapper; a row with no dpaUrl reads "Not linked". Under it, a
role=status line states the visible counts ("Showing 3 of 8 processors ·
region: European Union · DPA linked for 3 of 3") — always true of what is on
screen, in every filter state.
- "Notify me of changes" takes the shape of what the host wired: onSubscribe →
an email field validated on submit (loose pattern, noValidate, aria-invalid +
role=alert message) that reports only what it witnessed — "Change notices
requested for <address>", never "notices go to <address>", since the handler's
outcome never comes back — and offers "use a different address"; no handler
but a changeFeedUrl → that link; neither → one sentence naming the gap. The
confirmation is a role=status live region mounted in every shape (sr-only, so
no layout cost, while empty: a region inserted with its text already in it is
not announced), and focus follows the swap that unmounts the pressed button —
onto the confirmation on submit, back to the field on reset.
- Uptime is optional and prints truncated at two decimals (99.999 → 99.99%), so
the page can only understate it; no uptime data draws no tile at all.
- reviewedAt prints with its age in days and gains a badge past reviewStaleDays.
Rendering & styling
- Semantic tokens only: bg-card panels, border / border-dashed for unpublished
slots, text-muted-foreground for supporting copy, text-primary for links and
the audit glyph, destructive reserved for "refresh due" and a stale review.
- Container queries (@container/trust) drive the one- to two-column switch, so
the block adapts to its slot rather than to the viewport; cn() merges
className; every link is a real href from the contract, absolute ones opening
in a new tab with an sr-only "(opens in a new tab)".
- The only animation is the skeleton pulse and the retry spinner, both
motion-reduce:animate-none; focus-visible rings on chips, links and buttons.
Customization levers
- Sections: certifications, practices and subprocessors are sibling <section>s —
drop one, reorder them, or move the uptime tile out of the header without
touching the derivations.
- Density: swap the @2xl/trust:grid-cols-2 grids for three columns, or make the
certification cards a single list on narrow embeds.
- Freshness policy: defaultRefreshMonths for the house rule, per-certification
refreshMonths for frameworks that differ, reviewStaleDays for the page badge.
- Filter axis: facet on `region` today; the same facet + largest-remainder
helper works for a `purpose` or data-class column by changing one field name.
- Notification: keep the email capture, or pass no onSubscribe and let a
changeFeedUrl (RSS, changelog page) carry it.
- Icons: practices map icon keys to lucide glyphs through one record — extend
the enum and the record together; unknown keys already fall back to the shield.Concepts
- Report, don't certify — the framework name prints verbatim and the only derived judgement is months elapsed against the cadence the host itself supplied, so the page can be wrong about a date but never about a legal claim it invented.
- Freshness in calendar months — audit cycles are stated in months, so the age is counted the way an age is (month difference, minus one until the day of the month comes round), not as
days / 30.44, which disagrees by a whole month several times a year. - Absence renders as absence — a missing report link becomes the request route, a missing DPA reads "Not linked", missing uptime draws no tile, and an audit date the page could not read drops out of the freshness ratio instead of being counted as overdue; nothing is filled in with a default that would flatter the page.
- Blocked, explained — every affordance that cannot work says why in a sentence with the next step in it; the one affordance that does act claims only what it witnessed ("change notices requested for…"), then moves focus to that confirmation and announces it through a live region mounted long before it had anything to say.
- Largest remainder over the facets — the region shares are floored and the leftover points handed to the biggest fractions, so the chips add to exactly 100 instead of the 101 that per-chip rounding prints for three-of-eight splits.
- Derived counts, restated per filter — "DPA linked for 3 of 3" is computed from the rows currently on screen and announced through
role=status, so a filtered view never inherits the unfiltered claim.
SSO Setup
A four-state SAML/OIDC connection panel — copyable values in both directions, DNS domain proof, claim mapping, a test verdict that names the broken setting, and an enforcement switch that spells out who gets locked out.
Legal Consent
A versioned terms and privacy acceptance panel: what changed since the version on file, full text that must actually be reached before its box unlocks, separable consents, and the record it hands back.