Ansora UI
Components

Sign-off card

Who committed to this record, in which role, at what moment and in which version — the artifact an audit asks for, stated rather than enforced.

statusidentity

Install

pnpm dlx shadcn@4.23.0 add @ansora/sign-off-card

Examples

Usage

It asserts, it does not enforce. This card states who committed to a record, in which role, at which moment and in which version. It locks nothing, disables nothing and prevents nothing — a component cannot; the server does. There is no locked prop and there will not be one, because a false guarantee is this component's biggest API risk: a prop that reads like a lock switches off the check that would have caught an unlocked record. If your screen has to stop someone editing after a signature, do it on the server and let this card describe the outcome.

Two states, signed and declined, and no pending. That is a decision, not a gap. The inventory entry behind this item describes the post-signature artifact, which three products want: video's consultation detail (an audit record, read-only forever, so it renders no actions), after-care's care-plan version (read by a patient who can never amend it, so it renders no actions either) and the scribe's review screen after signing (the one of the three that does have an addendum action). That is the two-screens-in-two-products difference actions exists for. The pre-signature half — what is still blocking, how to carry irreversibility, where the confirmation lives — was asked for by ONE product, so under this repo's admission rule it is screen-level and belongs in that product's repo. What brings it back is a second product writing it down itself; at that point the readiness list is a registry item of its own — an ordered list of blocking and non-blocking findings, planned but not yet shipped here — and never a blockers prop on this card. Do not fuse the two halves. A card that reported readiness would have to render something when nothing is blocking, and "green: no conflicts" is forbidden here — green is the brand colour, never a state. Readiness is reported by absence.

The tone is derived from state; it is not a prop. Both outcomes are neutral. signed is neutral because a completed record is a correct FINAL STATE rather than an event, and green would additionally claim the record is good rather than made. declined is neutral and never critical: a refusal is a lawful outcome, and painting it red nudges staff to go back and ask again, which is the one behaviour a consent UI must not encourage. The two are told apart by statusLabel — required, so the outcome is never colour-only — and by a filled-vs-hollow glyph reusing @ansora/session-bar's vocabulary. Since a legal distinction rides on one shared tone, the word is the carrier that matters: write two words a reader can tell apart without seeing either frame.

--primary IS the brand green, so an unmodified <Button> for "Podepsat" is a green button — and it stays primary. Do not "fix" that. The alternatives are worse: red is forbidden on a lawful, deliberate act, and a bespoke "destructive-ish" variant means a new token in @ansora/tokens for one screen. Weight for an irreversible action comes from position (one primary button, alone, at the end of the document), from the sentence stating the consequence, and from the confirmation step — never from hue. Signing is not an error; it is the thing the screen is for.

Do not wrap this in a second card. The component's root already IS a @ansora/section-card; title is its visible heading and the status pill is its header action. The body supplies its own px-(--card-p) inset because SectionCard's content is full-bleed. className lands on the card root, which is where a width belongs. fieldsLabel is the invisible name of the <dl> and is required — an unnamed definition list is an anonymous group in a screen reader's rotor, and a record screen usually carries more than one.

Every signature field is required — name, role, at and version — so a record with no named signer or no verifiable time cannot be rendered at all. at is a Date and is formatted here, through formatDateTime from @ansora/core, rather than passed in pre-formatted: the convention allows either, but a wrong time zone on a signature is a legal defect, so this is the one place it is not delegable. The machine-readable toISOString() goes into <time datetime> beside it. The version row is typeset .identifier (slashed zero) and nothing else is — a version is read character by character; .num would right-align it and leave 0 indistinguishable from O.

The card announces nothing and owns no dialog. No aria-live, no role="status": the review screen already has up to three talkers, the outcome is rendered permanently and visibly, and the right response to a user-initiated, navigation-class act is to MOVE FOCUS to the new record — which is the screen's job, not this component's. The confirm dialog is the screen's too. Owning one would force "use client" on a component that is currently pure RSC, and alert-dialog is not installed in this repo at all.

Checklist for the confirmation you build yourself, because nothing here enforces any of it and pnpm shots opens nothing, so a dialog's contents are measured by nobody unless you render one already open in a fixture:

  • The consequence sentence is on the page, not only in the dialog. Put nothing load-bearing inside a dialog that is not also outside it. note is the slot for it.
  • The dialog is named by the same sentence the page shows, so the two cannot drift apart.
  • Initial focus goes to the least destructive control (the way out), never to the confirm button.
  • Esc cancels, and cancelling returns focus to the trigger.
  • The confirm button is at least 24x24 px, like every other control here. A static geometry sweep measures none of it: a dialog's controls are not in the DOM until something opens the dialog. Rendering one already open in a fixture is the way to bring them into range — and then check that your sweep's target count actually went up, because a popup portalled out of the tree it was measured in is invisible to the measurement while looking measured.
  • The trigger is not disabled by this card — the card disables nothing. If your screen must block signing, say why in text next to the button rather than leaving a dead control with no explanation.

Budget 23 elements for the record alone, 26 with a note and actions. Both figures come from a real react-dom/server render rather than a hand count, and they are what tells you whether a screen crosses Lighthouse's 800-element warning.

import { SignOffCard } from "@/components/ansora/sign-off-card"

<SignOffCard
  state="signed"
  title="Podpis zprávy"
  fieldsLabel="Záznam o podpisu"
  statusLabel="Podepsáno"
  signature={{
    name: "MUDr. Jana Krejčí",
    role: "Ambulantní lékař",
    at: signedAt,
    version: "3",
  }}
  captions={{ name: "Podepsal", role: "Role", version: "Verze zprávy", at: "Podepsáno" }}
  note="Podpisem se zpráva uzavře; další úpravy půjdou jen dodatkem."
  actions={<Button size="sm" variant="outline">Přidat dodatek</Button>}
/>

Files

Dependencies

  • @ansora/base
  • @ansora/status-pill
  • @ansora/section-card
  • @ansora/detail-fields

On this page