Content Credentials
A C2PA-flavoured provenance panel — a four-rung signature verdict, the signing issuer, the created/edited/exported chain, an expandable raw manifest, and a compact chip that opens the whole thing in a popover.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/content-credentials.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ContentCredentials" component: a content
provenance panel in the spirit of C2PA / Content Credentials. It renders a
signature verdict, who signed the manifest, the production chain that led to
this file, and the raw manifest behind a disclosure. Ships with a compact chip
that opens the same panel in a popover. Uses lucide-react for glyphs, the
Radix Popover primitive for the chip, and a shadcn Button for icon actions.
Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>
minus `title` (redefined as ReactNode). Rest props spread on the root.
- status?: "verified" | "checking" | "unverified" | "tampered" (default
"verified"). This is the whole state machine — there is no separate `loading`
or `error` flag.
- title?: ReactNode — overrides the verdict headline.
- description?: ReactNode — overrides the verdict sentence; `null` drops it.
- issuer?: { name: string; authority?: string; signedAt?: Date | string;
fingerprint?: string } — omit the whole object when there is no signature to
describe.
- chain?: readonly Step[] where Step = { id?, action?, actor, detail?, at?,
ai?, broken? }. `action` ∈ "captured" | "created" | "imported" | "edited" |
"exported" | "published" (default "edited") and picks the verb, the glyph and
the accent token. `[]` renders a "no production history" note; omitting the
prop drops the section entirely.
- manifest?: string | Record<string, unknown> — an object is pretty-printed
with JSON.stringify(value, null, 2); a string is shown byte for byte (use it
when the server already serialized it).
- manifestLabel?: string (default "Raw manifest"),
defaultManifestOpen?: boolean (default false),
onManifestOpenChange?: (open: boolean) => void — the hook for lazy fetching.
- formatDate?: (value: Date) => string — defaults to absolute UTC.
- footer?: ReactNode — policy link / "inspect elsewhere" action. The consumer
owns its onClick or href; render nothing clickable that does nothing.
- Second export: <ContentCredentialsBadge> — same data props plus label?,
side?, align?, panelClassName?; extends button props (className styles the
chip, panelClassName styles the panel inside the popover), forwardRef to the
button.
Behavior — the verdict ladder
- Four rungs, not a boolean, and "unverified" is the one that matters most:
a file with NO signature is not a forged file. Give it its own neutral
treatment (muted accent, dashed panel border, wording that says "unverified
is not the same as forged") so it can never be mistaken for the alarm state.
- Each verdict also fixes how much the chain below may claim:
verified → proof "signed"; checking and unverified → "claimed";
tampered → "disputed". Nothing else in the component decides trust.
- tampered: find the first step flagged `broken`. That link and everything
after it renders "disputed"; links ABOVE it are demoted to "claimed", never
left looking signed — the signature that would have proved them is the one
that just failed. With no step flagged, the entire chain is disputed.
- checking sets aria-busy on the root and spins the verdict glyph.
- One-shot settle: when `status` transitions to a settled rung, the verdict
medallion plays a single scale/fade pop. Implement it as a CSS animation on a
node keyed by a counter that only increments on a status change — a CSS
animation starts on node insertion, so re-keying replays it exactly once and
a parent re-render cannot retrigger it. No timer, nothing to clean up. Start
the counter at 0 so the first paint (server and client) carries no animation
class: a panel that mounts already verified must not pretend it just
finished checking.
Behavior — the chain
- Vertical rail: a dot per step, a connector between consecutive dots. The
connector is solid for "signed" and dashed for "claimed"/"disputed" —
the line itself says whether the link is proven.
- Colour is earned. A signed step keeps its action accent (a color-mix tint of
var(--chart-*)); a claimed step falls back to muted with a dashed dot ring;
a disputed step goes destructive. Never colour a claim you cannot back.
- Each step reads: action verb (small caps), optional "AI" marker for
model-produced output, optional "signature break" marker on the flagged
link, then the actor in the strongest weight, then detail, then timestamp.
Actor is the answer to "who did this", so it gets the emphasis, not the verb.
Behavior — timestamps
- Default format is absolute UTC ("2026-03-14 09:41 UTC"), derived from
toISOString(). Two reasons: a provenance timestamp is a forensic value that
must mean the same thing to the reader and to whoever they forward it to,
and a locale-dependent format would differ between server and client and
break hydration. Consumers who know they are client-only pass `formatDate`.
- A `string` timestamp passes through verbatim (already localized upstream);
an unparseable Date is dropped, never printed as "Invalid Date".
Behavior — raw manifest disclosure
- A <button aria-expanded aria-controls> toggling a region that stays MOUNTED
and uses the `hidden` attribute: aria-controls must point at a node that
exists, and reopening must not lose the reader's scroll position.
- The row shows the label and a line count; the body is a <pre> capped at
~16rem with its own scroll, so a 400-line manifest cannot stretch the panel.
- A copy control sits over the top-right of the body (with matching pre
padding so text never hides under it). Same control next to the certificate
serial: both values exist to be pasted into a ticket or a verifier.
- Copy control details: guard `navigator.clipboard` (absent on insecure
origins and in some webviews) and stay idle instead of throwing; swap the
icon for ~1.6s; clear the pending timeout before starting a new one so
double-clicks do not flip the icon back early; clear it on unmount so a
popover closing mid-timeout cannot set state on a dead node.
Behavior — the compact chip
- The chip is not a smaller panel; it is the affordance that admits a panel
exists. It carries the verdict glyph so bad news is visible without a click.
- It opens the FULL panel in a popover, never a tooltip: the panel contains
scrollable, selectable and copyable content a pointer must be able to reach.
- Portal the popover: the chip usually sits inside an image wrapper with
overflow-hidden that would clip an in-place panel. Let Radix own
aria-haspopup / aria-expanded / focus return.
Accessibility
- The verdict line is role="status" aria-live="polite": the flip from
"Checking credentials…" to a result is the single most important thing a
screen-reader user must hear, and it is the only live region carrying the
panel's own content.
- The copy buttons hold the one other live region: an sr-only role="status"
inside each button, mounted empty from the first paint and filled with
"Copied to clipboard" once the write resolves. It has to exist before the
message (a region created together with its first message is never
announced), and the icon swap on its own is silent.
- Issuer and chain are role="group" with aria-labelledby pointing at their
section labels (useId-derived), so the panel does not inject headings into
the host document's outline.
- The chain is an <ol> — order is meaning here.
- Every glyph is decorative and aria-hidden; the "AI" marker carries an
sr-only expansion ("produced by a model"); focus-visible rings on the
disclosure button, the copy buttons and the chip.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the surface, border,
bg-muted/40 for the issuer block, text-muted-foreground for secondary text,
text-destructive + border-destructive/40 + bg-destructive/10 for the disputed
treatment, ring / ring-ring for focus, and var(--chart-1..5) plus
var(--destructive) / var(--muted-foreground) as accents. Every tint is
color-mix(in oklab, <token> N%, transparent) — no hex, no rgb(), no oklch().
- Merge consumer className with cn() so `w-[22rem]` in the popover can
override the panel's own `w-full`.
- Reduced motion: the settle pop is motion-reduce:[animation:none], the
checking spinner motion-reduce:animate-none, the chevron rotation and the
popover enter/exit motion-reduce:*-none. Every verdict stays legible without
a single frame of animation.
- Long values (issuer names, actors, certificate serials) use wrap-anywhere and
min-w-0 so nothing widens the panel; the certificate serial is shown IN FULL
rather than middle-truncated — a serial you cannot read is a serial you
cannot compare.
Customization levers
- Verdict table: one Record<status, {title, description, icon, accent, proof,
history}> at the top of the file. Rewrite the copy, swap ShieldCheck /
ShieldAlert for your icon set, or repoint `accent` at var(--primary) — the
whole panel re-skins from that one table.
- Action vocabulary: the ACTIONS record is the closed verb list. Add
"translated" / "watermarked" / "redacted" rows, or collapse to
created/edited/published if your pipeline only ever asserts three.
- Sub-blocks are opt-in: pass only `status` for a one-line verdict card; add
`issuer` for signer details, `chain` for the timeline, `manifest` for the
evidence, `footer` for a policy link. Nothing renders a section it has no
data for.
- Density: the root's gap-4 and each step's pb-3 / size-7 dot are the only
spacing knobs; drop to gap-3 / pb-2 / size-6 for a sidebar, or raise for a
dedicated verification page.
- Chain proof mapping: if your validator can prove links individually, change
the disputed branch so links above the break stay "signed" instead of being
demoted — one expression, and the rest of the rendering follows.
- Manifest body: swap the <pre> for a syntax highlighter or a key/value table,
raise the max-height, or drop `manifest` entirely for consumer-facing pages
where the JSON would be noise.
- Chip: change `label` to a single glyph plus sr-only text for an overlay on
an image corner, or set side/align to open upward inside a card.Concepts
- Verdict ladder — four rungs instead of a checkmark, because "no signature found" and "the signature failed" are different facts; the unverified rung gets its own neutral wording and a dashed panel edge so it can never borrow the alarm treatment that belongs to a real mismatch.
- Trust decays below the break — the verdict picks a proof mode for the whole chain, and a flagged break splits it again: links after the break go destructive, links before it drop to "claimed" rather than staying confident, since the signature that would have proved them is exactly the one that failed.
- Colour is earned — accent tints are a reward for a signed link; unproven steps fall back to muted with a dashed rail and disputed steps to destructive, so the eye can read confidence off the timeline without reading a word.
- Disclosure, not decoration — the summary above is an interpretation, so the raw manifest sits one click away behind
aria-expanded, stays mounted while hidden (the control must point at a node that exists) and ships with a copy action, because a provenance UI that only ever shows its own summary is unfalsifiable. - One-shot settle — the verdict medallion pops exactly once per verdict: a CSS animation only starts when a node is inserted, so re-keying it on each status change is the whole lock — no timer, no replay on parent re-render, and no animation at all on first paint or under reduced motion.
- Chip as trigger, not summary — the compact variant carries only the verdict glyph and a label, and opens the same panel in a portalled popover instead of a tooltip, because everything inside is scrollable, selectable and copyable and a pointer has to be able to reach it.
Persona Editor
An authoring form for one assistant persona: a seed-derived face with a deterministic shuffle, trait chips that refuse rather than truncate, a tone select, budgeted instructions, a preview bubble recomposed on every keystroke, and a save bar that names which fields moved.
Rate Limit Meter
A live request-budget meter — a rolling-window gauge, a burst-dot timeline of recent calls, an escalating severity band, an expiry-aware forecast of when the limit lands, and a reset countdown once it does.